Das lokale Koordinatensystem ist das Koordinatensystem, in dem ein Objekt modelliert wird. Beispielsweise bezieht sich das Indexed Face Set immer auf die lokalen Koordinaten.
Die Model Transformation ist eine Transformationsmatrix, die an jedem Objekt hängt und definiert, wie dieses Objekt in die Weltkoordinaten platziert, orientiert und skaliert werden soll.
Der Eyespace (auch View Space, deutsch Aug(en)-Koordinaten) ist ein Koordinatensystem, bei dem die Kamera der Nullpunkt ist und alles andere relativ zur Kamera platziert ist. Mit der Viewmatrix lassen sich Punkte vom Worldspace in den Eyespace überführen.
Wir rechnen hier in 2D, das ganze läuft aber analog in 3D ab.
Für das einfachere Rechnen ist die Sehrichtung entlang der positiven y-Achse (statt in 3D entlang der negativen z-Achse).
Homogene Koordinaten
Ausgangslage sind Punkte im Eyespace. Die Idee: Wie jede Transformation lässt sich Perspektivische Projektion auch durch eine Matrix ausdrücken. In homogener Schreibweise sieht eine Matrix so aus:
Wegfall der linearen und Translationsmatrix
Wir können uns erschließen:
Wir sind im Eyespace schon passend skaliert / orientiert
Wir sind auch passend positioniert
Damit ist der Effekt von soweit:
Dehomogenisiert wäre das dann:
Homogene Werte
Wir sehen, wir haben eine Division. Zur Erinnerung an unsere Mission: und sollen kleiner werden, wenn die Entfernung größer wird. Wie machen wir das? Indem wir durch die Entfernung teilen. Das haben wir genau hier erreicht.
Weiter können wir uns erschließen:
ist eine konstante Skalierung, unabhängig der Position. Das brauchen wir nicht.
bezieht sich auf den -Wert, dementsprechend würden Dinge kleiner werden, je weiter oben sie sind. So funktioniert die Realität nicht, dinge werden nicht kleiner, je weiter oben sie sind.
Dementsprechend bleibt nur übrig, was sich genau auf , also die Distanz zur Kamera bezieht. muss also definiert sein, wir nehmen der Einfachheit halber mal .
Übrig bleibt die Matrix:
Erhaltung der Tiefenwerte
Dehomogenisiert wäre der Effekt soweit dann:
Problem damit: Bei der Transformation geht die Tiefe (-Achsenwerte) verloren. Deswegen könnten wir dann nicht mehr entscheiden, ob ein Vertex vor oder hinter einem anderen liegt. Lösung: Man kann die Tiefe erhalten, indem man setzt:
Wenn man jetzt nämlich einen Punkt transformiert:
Sieht man, dass nicht mehr konstant ist, sondern eine monoton steigende Funktion. Das ist wichtig, denn monoton steigende Funktionen behalten die Ordnung bei. Das heißt, Dinge, die bisher näher als andere waren, sind nach der Transformation immer noch näher als andere. Zwar sind die Werte nicht mehr linear, aber sie reichen trotzdem um zu bestimmen, welche Dinge vor anderen sind.
Es bleibt also die Transformationsmatrix:
Beispiel
Gegeben sind folgende Punkte im Eyespace:
Die transformierten Punkte sind dann:
3D
Die Projektion in 3D läuft analog ab, mit der Änderung dass wir jetzt in die negative -Achse schauen. Hinzu kommt die Transformation in den NDC:
Dementsprechend verläuft die Transformation wie folgt:
Auch hier beziehen wir die Z-Achse wieder mit ein, sodass sie in Sichtrichtung (negatives z) monoton steigend ist.
Die Normalized Device Coordinates (NDC) werden verwendet, um Szenen vom Viewspace in den Wertebereich zu bringen, mit dem die Grafikkarte rechnen kann.
Vorgehen
Erst schneiden wir alle - und -Werte ab, die außerhalb unseres Viewport () liegen. Die -Achse wird auch getrimmt (). Anschließend normalisieren wir die Werte auf .
Das Signed-Distance-Rasterisierungsverfahren ist der quasi-Standard zum Rasterisieren von Dreiecken.
Voraussetzungen
Die Verticies der Dreiecke sind gegen den Uhrzeigersinn angeordnet. Dies entspricht auch dem Standard in der Mathematik.
Grundidee
Wir malen ein Pixel an, wenn dessen Mittelpunkt im Dreieck liegt. Zum bestimmen, ob ein Punkt in einem Dreieck liegt, geht man die Vektoren des Dreiecks ab. Wenn ein Punkt links von jedem der Vektoren des Dreiecks liegt, dann ist er innerhalb des Dreiecks.
Beispiel
Der Punkt ist innerhalb des Dreiecks, denn er ist links von jedem der Dreiecksvektoren , und .
Der Punkt ist nicht innerhalb des Dreiecks. Er ist zwar links von den Vektoren und , aber nicht links vom Vektor .
Der Punkt wäre links von , wenn das Ergebnis positiv wäre. Es ist aber negativ, deswegen ist nicht links von .
Algorithmisch
Wendet man diese Regel für alle 3 Kanten des Dreiecks an, kann man bestimmen, ob ein Punkt im Dreieck ist.
Optimierung
Bounding Box, die das Dreieck so enganliegend umschließt wie möglich
Notizen aus der VL
Signed-Distance Rasterisierungsverfahren
Standard
Dreiecke rotieren standardmäßig in der Mathematik gegen den Uhrzeigersinn.
Idee des Algorithmus: Man geht die Vektoren des Dreiecks ab. Wenn bei jedem der 3 Vektoren der Punkt links von dem Vektor ist, dann ist er innerhalb. Ist der Punkt nicht an allen Vektoren links, ist er außerhalb.
Idee (tafel): Wir gehen den (konvexen) Polygonzug entlang und prüfen, ob der gegebene Punkt immer auf der Seite “nach innen” ist. Heißt: Polygonzug -> gerichtete Kanten:
algorithmisch:
Oft hilft es Richtungen zu Normalisieren:
“Die Richtung von a nach b ist der Vektor b-a geteilt durch seine Länge (=> normalisiert)”
Aus einer Richtung in 2D kann direkt eine orthogonale Richtung, eine Normale, abgedeckt werden:
“Die Normale von einer Richtung.”
Man tauscht die Komponenten und bei einer streut man ein negatives Vorzeichen rein
Skalarprodukt
Wir haben 2 Vektoren 𝕣, dann ist das Skalarprodukt (in der Computergrafik so geschrieben, “bracket notation”), alternativ , aus der Grundschule eher bekannt
Auch gut zu Wissen: .
Dementsprechend ist das Skalarprodukt von 2 orthogonalen Richtungen = 0.
=> Wann ist der Punkt p links von der Kante b-c ?
Wenn
Also wenn ist, dann ist p links von der Kante.
Performance Optimierung
-> Bounding Box, die das Dreieck so enganliegend umschließt wie möglich
Bei der perspektivischen Projektion in 3D schauen wir in Richtung der negativen z-Achse. Um die Informationen zu erhalten, welche Dinge vor anderen liegen, wird z auf eine monoton steigende Funktion überführt.
Problem hierbei: Die Funktion ist zwar im Sichtbereich monoton steigend, ändert aber für positive z (hinter der Kamera) ihre Richtung:
Beispielhafter Plot für und
So würde alles was hinter der Kamera ist vor die Kamera transformiert werden.
Um das zu verhindern, wendet man Clipping an.
Vorgehen
Beim Clipping schneidet man Verticies ab, die außerhalb des Frustums liegen.
Double Buffering ist ein Konzept aus dem Rendering, bei dem mit 2 Framebuffern gearbeitet wird.
Dabei kommt das aktuelle Bild, das der Monitor zeigt immer aus dem Front Buffer. Währenddessen kann das nächste Bild im Back Buffer gezeichnet werden. Um das fertig gezeichnete Bild schließlich anzuzeigen, wird ein Buffer Swap ausgeführt, der die beiden Puffer tauscht.
Die Baryzentrische Inteprolation ist eine Interpolation auf einem Dreieck, also zwischen 3 Koordinaten.
Gegeben sind die Werte , , an den Dreieckspunkten, zwischen denen interpoliert werden soll und die baryzentrischen Koordinaten, an denen interpoliert wird.
In OpenGL kann man Vertex Shader und Fragment Shader definieren. Diese werden im Setup zu einem Shader Programm zusammengefasst, welches beim Draw-Call in der Render-Pipeline automatisch ausgeführt wird. Dafür müssen einige Sachen vorbereitet werden:
Schlägt das Kompilieren fehl sollten die Shader und Programme gelöscht werden.
Shader können auch direkt nach dem erfolgreichem Linken gelöscht werden. Davor sollten sie noch mit glDetachShader vom Programm gelöst werden.
Das Shader Programm sollte dann natürlich erst gelöscht werden, wenn es nicht mehr zum Rendern benötigt wird.
Beispiel
// 1. Shader Program erstellenGLuint shader_prog = glCreateProgram();// 2.1. Shader erstellenGLuint vert_shader = glCreateShader(GL_VERTEX_SHADER);// 2.2. Shader-Quellcode in den Shader ladenconst char *vert_source_cstr; // Sei hier mal Definiert und NullterminiertglShaderSource(vert_shader, 1, &vert_source_cstr, 0);// 2.3. Shader kompilierenglCompileShader(vert_shader);// Check ob es Kompilierfehler gabGLint result = GL_FALSE;glGetShaderiv(vert_shader, GL_COMPILE_STATUS, &result);if (result != GL_TRUE) { cerr << "Failed to compile shader" << endl; // Aufräumen glDeleteShader(vert_shader); glDeleteProgram(shader_prog); shader_prog = -1; exit(-1);}// 2.4. Shader an das Program anheftenglAttachShader(shader_prog, vert_shader);// (2.1. bis 2.4. analog nochmal für den Fragment Shader)// 3. Shader Program linkenglLinkProgram(shader_prog);// Check auf Linkerfehlerresult = GL_FALSE;glGetProgramiv(shader_prog, GL_LINK_STATUS, &result);if (result != GL_TRUE) { cerr << "Failed to link shaders" << endl; // Aufräumen glDeleteShader(vert_shader); glDeleteShader(frag_shader); glDeleteProgram(shader_prog); shader_prog = -1; exit(-1);}// Alles lief durch! Wir können den Shader jetzt verwenden:glUseProgram(shader_prog);
GL_ELEMENT_ARRAY_BUFFER - Zum Speichern von Indizes (von z.B. Indexed Face Sets)
GLuint buffer - Die Adresse des Buffers.
Beispiel
// Buffer an den Port bindenglBindBuffer(GL_ARRAY_BUFFER , vbo);// Jetzt können Daten an den Port geschickt werden, die dann im Buffer landen// Port wieder entbindenglBindBuffer(GL_ARRAY_BUFFER , 0);
In OpenGL besteht die Möglichkeit, mehrere Attribute pro Vertex zu verarbeiten. Um ein Attribut von einem Buffer in den Shader zu streamen, muss zuerst wieder das Buffer Object an einen Binding Point gebunden werden. Anschließend kann ein Input-Stream definiert werden, der dann implizit von dem gebundenen Buffer liest. Danach kann der gebundene Buffer wieder gelöst werden. In der Praxis geht das so:
Falls wir mit Indexed Face Sets arbeiten, müssen wir dieses noch an den Binding Point GL_ELEMENT_ARRAY_BUFFER hängen. Dadurch ist in OpenGL automatisch der Index-Stream definiert und die Primitive werden später richtig konstruiert.
Beispiel
// vbo1 als ersten Shader-Input definierenglBindBuffer(GL_ARRAY_BUFFER, vbo1);glEnableVertexAttribArray(0);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);// vbo2 als zweiten Shader-Input definierenglBindBuffer(GL_ARRAY_BUFFER, vbo2);glEnableVertexAttribArray(1);glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);
glVertexAttribPointer - Definiert einen Input Stream für den Vertex Shader. Als Stream-Quelle wird dabei der Buffer verwendet, der zur Zeit des Aufrufs an GL_ARRAY_BUFFER hängt.
Den Großteil (Clipping, Rasterisieren, …) erledigt OpenGL von selbst, allerdings können wir uns mit Shadern in bestimmten Abschnitten in bestimmte Phasen einklinken:
Fragment Shader durchlaufen alle “Fragmente” (also einfach gesagt alle rasterisierten Pixel). Hier können wir den Pixeln z.B. eine Farbe geben oder Lichteffekte hinzufügen.
Beispiel
// vbo1 als ersten Shader-Input definierenglBindBuffer(GL_ARRAY_BUFFER, vbo1);glEnableVertexAttribArray(0);glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);// vbo2 als zweiten Shader-Input definierenglBindBuffer(GL_ARRAY_BUFFER, vbo2);glEnableVertexAttribArray(1);glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);// ibo als Indizies nochmal binden, sofern nicht schon gemachtglBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);// Draw Call!glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);// Bindungen lösenglBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);glBindBuffer(GL_ARRAY_BUFFER, 0);
GL_TRIANGLE_STRIP - Dreiecks-Strip. Dabei werden neue Punkte mit den zwei letzten Punkten aus dem vorherigem Dreieck verbunden. Eignet sich gut für zusammenhängende Flächen.
GL_QUADS- Quadrate
GLsizei count - Anzahl der Verticies die genutzt werden sollen
Damit OpenGL auf der GPU rechnen kann, müssen wir alle nötigen Daten (z.B. die Positionen der einzelnen Vertices oder das Indexed Face Set) bereitstellen. Die Daten speichern wir in einem Buffer Object. Das ist einfach Speicher auf der GPU. Auf den haben wir allerdings keinen direkten Zugriff, sondern nur über einen Buffer Binding Point. Diesen Binding Point können wir mit dem Buffer verbinden, um dann Daten über den Zielport an den Buffer zu speichern. Konkret geht das so:
Die Daten über Ziel an gebundenen Buffer senden mit glBufferData
Beispiel
// Die seien hier mal schon definiertvector<vec3> vertices;vector<unsigned> indices;// 1. Buffer anlegenGLuint vbo;GLuint ibo;glGenBuffers(1, &vbo);glGenBuffers(1, &ibo);// 2. Buffer an ein Ziel bindenglBind(GL_ARRAY_BUFFER, vbo); // 3. Daten über Ziel an Buffer sendenglBufferData( GL_ARRAY_BUFFER, vertices.size() * sizeof(vec3), &vertices[0], GL_STATIC_DRAW); // Bindung lösenglBind(GL_ARRAY_BUFFER, 0); // Analog für den Index BufferglBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);glBufferData( GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned), &indices[0], GL_STATIC_DRAW);glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); // Bindung lösen
Wir haben gesehen, wie man in OpenGL rasterisiert. Dieses Vorgehen funktioniert gut wenn man nur wenige Meshes hat. Das Problem: Tatsächlich arbeitet man ja mit einigen hunderten Meshes. Und da jeder dieser Aufrufe ein Call der CPU auf die GPU, ist, dauert das. Oft ist dann nicht mehr die GPU das Bottleneck sondern die CPU, die extrem viele Calls an die GPU senden muss.
Beispiel
// Für jedes der hunderten Meshes muss das passierenfor (int i = 0; i < meshes.size(); i++) { // Inputs binden glBindBuffer(...) glEnableVertexAttribArray(...) glVertexAttribPointer(...) glBindBuffer(...) glEnableVertexAttribArray(...) glVertexAttribPointer(...) glBindBuffer(...) // Draw Call! glDrawElements(...); // Bindungen lösen glBindBuffer(..., 0); glBindBuffer(..., 0);}
Lösung
In OpenGL löst man dieses Problem mit sogenannten Vertex Array Objects. Ein VAO kann man sich als “Recorder” vorstellen, das solange es gebunden ist aufzeichnet, welche Vertex-Input-Streams definiert werden. Dadurch müssen beim Draw-Call die einzelnen Vertex-Inputs nicht jedes mal neu definiert werden, sondern lediglich der VAO gebunden. Der lädt die Konfiguration dann automatisch. In der Praxis sieht das dann so aus:
glAttachShader(shader_prog, vert_shader);glAttachShader(shader_prog, frag_shader);// Stream an Index 0 wird mit "local_vertex" verknüpftglBindAttribLocation(shader_prog, 0, "local_vertex");glLinkProgram(shader_prog);
Eine Skybox ist eine Art der Environment Map, die eine Himmeltextur enthält. Skyboxen werden meist entweder in Cube Maps oder Longitude/Latitude Maps gespeichert.
Longitude / Latitude Maps
Hier können die Texturkoordinaten abhängig von der Kameraorientierung berechnet werden:
Mit Normal Maps lassen sich Normalen auf einer Textur definieren. Das ist Hilfreich um z.B. mehr Details in der Beleuchtung zu erzielen ohne mehr Primitive zu rendern.
Normal Maps werden als reguläre Textur gespeichert und im Tangentialen Koordinatensystem interpretiert. Die -Werte der Textur mappen also auf die xyz- bzw. -Werte der Normale im Tangent Space.
Berechnung
Normalen werden oft für Lighting benutzt (siehe z.B. Phong-Beleuchtungsmodell). Problem: Normalen sind im Tangent Space, andere Lighting-Vektoren (z.B. Lichtvektor ) sind im World- oder Eyespace. Um korrekt rechnen zu können, müssen wir alle in das selbe Koordinatensystem überführen.
Eine Option wäre, die Normale in den Worldspace bzw. Eyespace umzuformen. Das geht so:
Option 2: Im Tangent Space rechnen
Alternativ könnte man auch alle Vektoren in den Tangent Space zu überführen und das Lighting dort zu berechnen. Einen Lichtvektor würde man dann z.B. so umrechnen:
(Analog wird das auch für alle anderen für das Lighting relevanten Vektoren gemacht.)
Aus Performance-Gründen wird meistens Option 2 gewählt, weil die Multiplikation mit der TBN-Matrix hier im Vertex-Shader berechnet werden kann. Bei Option 1 würde man die Normale brauchen, die nur im Fragment-Shader läuft. Und da der Fragment-Shader öfter läuft als der Vertex-Shader, ist es aus Performance-Sicht günstiger, die Berechnung im Vertex-Shader vorzunehmen.
Die im Vertex-Shader berechneten Richtungsvektoren können dann an den Fragment-Shader weitergegeben werden und werden interpoliert.
Normal Maps lassen sich schlecht Mip-Mappen, weil wir hier nicht einfache Farben averagen, sondern Richtungsvektoren. Probleme, die auftreten können sind unter anderem:
Normalen die in entgegenliegende Richtungen zeigen werden geaveraget, das Ergebnis zeigt in die Mitte. Wir haben auf einmal ein Specular Highlight, da wo eigentlich keins ist
Der Average von zwei Normalen ist nicht normalisiert, heißt Lichter werden heller oder Dunkler
Eine mögliche Implementierung von Normal Maps in GLSL mit Lighting-Berechnung im Tangent Space könnte so aussehen:
Vertex Shader
// Normalein vec3 in_norm; // Tangente wird vor dem Vertex-Shader berechnet,// weil hierfür alle drei Vertices bekannt sein müssen.in vec3 in_tan; // Tangente// Im Tangent Spaceout vec3 direction_ts;void main() { vec3 bi_tan = cross(in_norm, in_tan); // Bi-Tangente // TBN vom Model-Space in Worldspace rechnen vec3 T = normalize(vec3(model_normal * vec4(in_tan, 0))); vec3 B = normalize(vec3(model_normal * vec4(bi_tan, 0))); vec3 N = normalize(vec3(model_normal * vec4(in_norm, 0))); // Tangent Space aufsetzen mat3 TS = transpose(mat3(T, B, N)); // Richtungen in den Tangent Space rechnen direction_ts = normalize(TS * direction_ws); /* ... */}
Fragment Shader
Normal Maps sind Texturen, die wie gehabt in den Shader geladen und ausgelesen werden können.
in vec2 tc;uniform int has_normalmap;uniform sampler2D normalmap;void main() { /* ... */ // Fallback für die Normale vec3 N = vec3(0,0,1); if (has_normalmap == 1) { // Normalenrichtung aus der Textur holen vec4 n = texture(normalmap, tc); // Normale aus Richtung bauen. // Wertebereiche müssen gemapped werden: // rgb: [0, 1] --> xyz: [-1, 1] // Umrechung mit x --> 2x - 1 N = normalize(vec3( n.r * 2 - 1, n.g * 2 - 1, n.b * 2 - 1 )); } // N kann jetzt für z.B. Phong-Lighting verwendet // werden, vorausgesetzt alle anderen Richtungen // sind auch im Tangent Space. /* ... */}
Mask Textures (auch Alpha-Maps) erlauben es uns, detailliertere Fragmente zu rendern als wir eigentlich Primitive haben. Das Funktioniert, indem wir Masken definieren, die bestimmen ob wir ein Fragment rendern oder nicht. Ist die Maske an, wird es wie gehabt gerendert. Ist die Textur aus, rendern wir es nicht. Das kann mit dem GLSL-Statement discard; gemacht werden.
Um die Texturen werden oft ähnliche Farben gelegt, weil heruntergesampelte Varianten (z.B. durch Mip-Mapping) die Randfarben der Textur miteinbeziehen. So wird vermieden, dass die Texturfarbe einen schlechten Durchschnitt macht.
Das Texturmapping ist eineinfacher Ansatz, Details nicht nur auf Vertex-Ebene, sondern auch zwischen Vertices zu definieren. Dabei werden Bilder (Texturen) über die einzelnen Primitive gelegt.
Oft werden dabei für eine Textur Texturkoordinaten, meistens im Bereich vergeben um den Wert der Textur an einer bestimmten Stelle parametrisieren zu können. Die Koordinaten werden pro Vertex definiert und dazwischen interpoliert.
Eine mögliche Implementierung von Texture Mapping in GLSL könnte so aussehen:
Vertex Shader
in vec3 in_pos; // Vertex-Positionin vec2 in_tc; // Texturkoordinatenuniform mat4 mvp; // Transformationout vec2 tc;void main() { // Textur-Koordinaten weiter an den // Fragment Shader (werden dann interpoliert) tc = in_tc; gl_Position = mvp * vec4(in_pos, 1.0);}
Fragment Shader
Texturen kommen als Typ sampler2D in den Shader. Wir können den Wert an einer bestimmten Texturkoordinate mit der Funktion texture(sampler2D texture, vec2 uv) bekommen.
Die Ambiente Komponente wird genutzt, um auch Objekte Leuchten zu lassen, an die kein direktes Licht kommt (da sie sonst schwarz wären).
Idee: Eine Konstante (abhängig vom Material) an jeder Stelle dazuaddieren.
Berechnung
Wir können einstellen:
“Ambiente Farbe” - mit welcher Farbe die Oberfläche ambient leuchten soll.
Gegeben dann noch wie viel Ambientes Licht es gibt , dann berechnet sich die ambiente Komponente an der Stelle so:
Diffuse Komponente
Mit der Diffusen Komponente lassen sich diffuse Reflexionen modellieren, z.B. die von matter Farbe, Stoff oder unpoliertem Holz.
Idee: Abhängig vom Winkel, in dem das Licht auf das Material einfällt, verstärkt oder weniger verstärkt reflektieren. Die Kameraposition ist dabei egal. Grundsätzlich wird bei kleinen Winkeln am meisten reflektiert. Je größer der Winkel wird, desto weniger wird reflektiert.
Warum ist das so?
Zwei Lichtstrahlen haben die selbe Intensität. Auf der Fläche hat ein Lichtstrahl wegen seines größeren Einfallswinkels eine größere Fläche. Dadurch hat das Licht da pro Fläche eine kleinere Intensität.
Die abfallende Intensität mit größer werdendem Einfallswinkel wird mit Hilfe des Lambertschen Gesetz modelliert. Dabei nutzen wir den Cosinus, der wachsendem Winkel abnimmt, also genau das Verhalten was wir brauchen.
Berechnung
Wir können einstellen:
“Diffusionsfarbe” - welche Farbe die Diffuse Reflexion haben soll.
Gegeben ist zusätzlich die eingehenden Lichtintensität und der Lichteinfallswinkel . Die diffuse Komponente berechnet sich dann so:
Im Shader wird aus effizienzgründen mit der Cosinus ersetzt durch das Skalarprodukt der Normalen:
Gleichzeitig müssen wir darauf achten, dass negative Einfallswinkel nicht in die Berechnung einfließen:
Notation
Statt kann man auch schreiben, die beiden Ausdrücke sind äquivalent.
Spiegelnde Komponente
Mit dieser Komponente lassen sich glänzende Reflexionen modellieren, wenn die Lichtquelle direkt in der Reflexion sichtbar ist, z.B. auf Metall.
Anmerkung
Wir simulieren hier nur Reflexionen von Lichtquellen, nicht Reflexionen von anderen Objekten. Dafür brauche wir eine ganz andere Klasse von Algorithmen, siehe Raytracing.
Idee: Wir wollen das Licht nicht einfach nur spiegeln (dann würde man z.B. Punktlichtquellen nie sehen), sondern es auf der Oberfläche in einen kleinen Bereich streuen. Die Streuung nennt man auch Specular Highlight. Wir verringern also die Intensität der Reflexion um die Reflexionsrichtung, sodass in einem gewissen Bereich immer noch ein Anteil reflektiert wird.
Modelliert wird das durch einen potenzierten Cosinus von der Winkeldifferenz zur Reflexionsrichtung. Der Cosinus nimmt ja mit wachsender Winkeldifferenz ab. Durch die Potenz können wir zusätzlich einstellen, wie schnell der Cosinus abnimmt, also wie “breit” der Streubereich ist.
Berechnung
Wir können einstellen:
“Shinyness exponent” - wie breit das Licht gestreut werden soll.
Kleine Werte führen zu diffusen Reflexionen
Große Were führen zu mehr glänzenden Reflexionen
Reflexionsfarbe - welche Farbe die Reflexion haben soll.
Gegeben ist dann die Winkeldifferenz zur Reflexionsrichtung und die eingehende Lichtintensität . Dann berechnet sich die Spiegelnde Komponente so:
Mit und dem ausschließen von negativen Werten ergibt sich dann:
Anmerkung
Wir sehen, dass für diese Formel die Kameraposition gebraucht wird. Oft wird deshalb die Lichtberechnung im Eyespace gemacht, weil hier die Kamera ja der Nullpunkt ist. Damit fällt diese Variable weg.
Reflexionsrichtung bestimmen
Beim Rendern haben wir aber keinen direkten Zugriff auf die Reflexionsrichtung . Das ist aber kein Problem, denn die können wir uns aber durch eine kleine geometrische Überlegung berechnen:
Wir können die strecke, die die Lichtrichtung an der Normale hochgeht mit dem Skalarprodukt berechnen:
Multiplizieren wir die Strecke mit dem zweifachen der Normalen, kommen wir an die doppelte Strecke der Lichtrichtung an der Normalen entlang:
Wenn wir jetzt die Lichtrichtung abziehen, kommen wir genau an der Reflektionsrichtung raus:
Zusammenfassung
Nimmt man alle Komponenten zusammen und die Lichtquellen, dann ist das gesamte Licht eines Punktes :
Hier ist eine Möglichkeit, um in GLSL jedes Primitiv in einer “zufälligen” Farbe zu rendern:
Fragment Shader
gl_PrimitiveID ist eine eindeutige Integer die innerhalb eines Primitiv gleich ist. Eine Farbpalette können wir im Voraus generieren und anschließend per Uniform hochladen.