sponza-normalmapping.jpg
sponza-skybox.jpg

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:

Eine mögliche Implementierung einer Skybox durch Longitude / Latitude Maps in GLSL könnte so aussehen:

Fragment Shader

#define PI 3.1415926535897932384626433832795
 
vec3 d = normalize(pos_wc - cam_pos);
 
vec2 uv = vec2(
	0.5 + atan(d.z, d.x) / (2 * PI),
	0.5 - asin(-d.y) / PI
);
 
out_col = vec4(texture(tex, uv).rgb, 1);

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.

Option 1: Im Worldspace bzw. Eyespace rechnen

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.)

Anmerkung

Da ein Orthonormalsystem ist, ist die inverse Matrix gleich der transponierten Matrix:

Anmerkung: Performance

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.

Mip-Mapping

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

// Normale
in 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 Space
out 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.
	
	/* ... */
}
sponza-masking.jpg
sponza-texturen.jpg
sponza-phong.jpg
sponza-dreiecke.jpg
sponza-farben.jpg

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.

Eine mögliche Implementierung von Mask Textures in GLSL könnte so aussehen:

Fragment Shader

Die Alpha-Map wird als Uniform Texture importiert. Ein Flag kann helfen, ob die Map überhaupt existiert.

uniform int has_alphamap;
uniform sampler2D alphamap;

Der eigentliche Check ist dann ein one-liner:

if (has_alphamap == 1 && texture(alphamap, tc).r < 0.5) discard;

Mit discard können wir OpenGL sagen, dass er dieses Fragment nicht zeichnen soll.

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-Position
in vec2 in_tc; // Texturkoordinaten
 
uniform mat4 mvp; // Transformation
 
out 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.

in vec2 tc; // Interpolierte Texturkoordinate
 
uniform sampler2D color; // Geladene Textur
 
out vec4 out_color;
 
void main() {
    out_color = texture(diffuse_texture, tc).rgba;
}

Das Phong-Beleuchtungsmodell (nicht zu verwechseln mit Phong Shading) ist ein einfaches lokales Beleuchtungsmodell. Es besteht aus 3 Komponenten:

Ambiente Komponente

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.

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 :

Ambiente Komponente

  • ist die ambiente Farbe am Fragment
  • ist die Intensität der ambienten Beleuchtung

Pro Lichtquelle :

  • ist die Anzahl der Lichtquellen in der Szene
  • ist die Intensität der Lichtquelle

Diffuse Komponente

  • ist die diffuse Farbe am Fragment
  • ist die Normale am Fragment
  • ist die Richtung vom Fragment zur Lichtquelle

Spiegelnde Komponente

  • ist die spekulare Farbe am Fragment
  • ist die Richtung von Fragment zur Kamera
  • ist die Reflexionsrichtung der Lichtquelle, also
  • ist der Shininess-Exponent am Fragment

Eine mögliche Implementierung der diffusen und spekularen Komponente des Phong-Beleuchtungsmodell in GLSL könnte wie folgt aussehen:

Fragment Shader

// Helper: Skalarprodukt mit nur positiven Ergebnissen
float dot_p(vec3 a, vec3 b) {
    return max(dot(a, b), 0);
}
 
// Phong-Shading
vec3 phong(
    float I_in, // Lichtintensität
    vec3 l, // Richtung zum Licht
    vec3 v, // Richtung zur Kamera
    vec3 n, // Normale
    vec3 k_diff, // Diffuse Farbe
    vec3 k_spec, // Spekulare Farbe
    float n_s // Shininess-Exponent
) {
    if (dot(n, l) < 0) return vec3(0);
    
    vec3 diff = k_diff * dot_p(n, l);
 
    vec3 r = 2 * n * dot(n, l) - l;
    vec3 spec = k_spec * pow(dot_p(v, r), n_s);
 
    return I_in * (diff + spec);
}

Eine mögliche Implementierung in GLSL, um pro Model eine Farbe zu rendern, könnte so aussehen:

Fragment Shader

// Die Farbe kommt aus dem Model
uniform vec4 k_diff;
 
out vec4 out_col;
 
void main() {
	// Direkt weiterleiten
	out_col = k_diff;
}

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.

uniform vec4 cols[100];
 
out vec4 out_col;
 
void main() {
	out_col = cols[gl_PrimitiveID % 100];
}