Rendering-Pipeline

Rendering-Pipeline für CG - Kurzübersicht

Das lokale Koordinatensystem ist das Koordinatensystem, in dem ein Objekt modelliert wird. Beispielsweise bezieht sich das Indexed Face Set immer auf die lokalen Koordinaten.

Model Matrizen

$M$

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.

Die Weltkoordinaten beschreiben die Koordinaten, die aus den lokalen Koordinaten mehrerer Objekte transformiert wurden.

Viewmatrix

$V$

Die Viewmatrix ist eine Matrix, die genutzt wird um Punkte aus den Weltkoordinaten in den View Space zu überführen. Sie ist wie folgt definiert:

Wobei die Eyespace-Matrix ist.

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.

Projektions-Matrix

$P$

Hinführung

Anmerkungen

  • 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:

  1. Wir sind im Eyespace schon passend skaliert / orientiert
  2. 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:

  1. ist eine konstante Skalierung, unabhängig der Position. Das brauchen wir nicht.
  1. 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.
  1. 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.

${\Delta}$

Dehomo-genisierung

$H(\dots)$

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 .

Window Transform

$W$

Rasterisierung

Die Viewport Coordinates bestimmen, wo auf dem Bildschirm das Bild gerendert werden soll.

Gegeben sind dafür die Breite und Höhe des Viewports, sowie den Abstand zum Nullpunkt .

Zur Umrechnung von NDC nutzt man die Matrix , die sich wie folgt berechnet:

$ [[\square]]$

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 .

Mathematische Bestimmung

Ein Punkt ist links von einer Kante , wenn:

Dabei ist die Normale nach links.

Beispiel

Gebeben:

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

???

Problemstellung

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.

Wann wird geclippt?

Generell wird nach der perspektivischen Projektion und vor der Dehomogenisierung geclippt.

Link zum Original

Dreiecke und Pixel

Rasterisierung

Transformation

Projektion

OpenGL

Kurzübersicht

OpenGL CG Kurzübersicht

Shader
Per Frame
Pipeline Draw-Call
Eingabestreams definieren
Draw Call
Streamdefinitionen wiederverwenden
Daten an die GPU senden

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.

Das verhindert Flackern und halbfertige Bilder.

Shader unbinden

  • [[glUseProgram]]

Buffer Swap

Context::swap_buffers();

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.

glGetShaderiv - Gibt Informationen über den Shader zurück

void glGetShaderiv(GLuint shader, GLenum pname, GLint *params);
  • GLuint shader - Der Shader, über den Informationen abgefragt werden sollen
  • GLenum pname - Das Parameter, das abgefragt werden soll, z.B.:
    • GL_COMPILE_STATUS - Frägt ab, ob der Shader erfolgreich kompiliert wurde
  • GLint *params - Adresse, an die die Informationen geschrieben werden sollen

Beispiel

Um zu checken, ob ein Shader korrekt kompiliert wurde:

GLint result = GL_FALSE;
glGetShaderiv(vert_shader, GL_COMPILE_STATUS, &result);
 
// ist result nun false, hat der shader nicht kompiliert.

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:

  1. Shader Program erstellen mit glCreateProgram
  2. Für jeden Shader:
    1. Shader erstellen mit glCreateShader
    2. Shader-Quellcode in den Shader laden mit glShaderSource
    3. Shader kompilieren mit glCompileShader
    4. Shader in das Shader Program einbinden mit glAttachShader
  3. Shader Program linken mit glLinkProgram

Erst dann ist das Shader Program bereit genutzt zu werden:

Zum Löschen:

Löschen

  • 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 erstellen
GLuint shader_prog = glCreateProgram();
 
// 2.1. Shader erstellen
GLuint vert_shader = glCreateShader(GL_VERTEX_SHADER);
 
// 2.2. Shader-Quellcode in den Shader laden
const char *vert_source_cstr; // Sei hier mal Definiert und Nullterminiert
glShaderSource(vert_shader, 1, &vert_source_cstr, 0);
 
// 2.3. Shader kompilieren
glCompileShader(vert_shader);
 
// Check ob es Kompilierfehler gab
GLint 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 anheften
glAttachShader(shader_prog, vert_shader);
 
// (2.1. bis 2.4. analog nochmal für den Fragment Shader)
 
// 3. Shader Program linken
 
glLinkProgram(shader_prog);
 
// Check auf Linkerfehler
result = 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);

Referenz

glCreateShader - Erstellt einen Shader (also einen Teil eines Shader Programms).

GLuint glCreateShader(GLenum shader_type);
  • GLenum shader_type - Der Typ des Shaders, z.B.:

Rückgabewert: GLenum - Eine Adresse über die der Shader referenziert werden kann.

glShaderSource - Weist einem Shader Quellcode zu.

void glShaderSource(
	GLuint shader, 
	GLsizei count, 
	const GLchar **string,
	const GLint *length
);
  • GLuint shader - Das Shader Objekt, generiert mit glCreateShader
  • GLsizei count - Wie viele Strings (“dateien”) übergeben werden sollen
  • const GLchar **string - Array an GLSL Shader strings
  • const GLint *length - Länge derShader strings. Kann man auf 0 setzen, um anzugeben dass die Strings Nullterminiert sind.

glUseProgram - Bindet ein Shader Programm

void glUseProgram(GLuint program);
  • GLuint program -

glAttachShader - Verbindet einen Shader mit einem Shader Programm

void glAttachShader(GLuint program, GLuint shader);

glCompileShader - Kompiliert einen Shader.

void glCompileShader(GLuint shader);
  • GLuint shader - Shader, der kompiliert werden soll

Draw Call

  • [[glDrawArrays]]
  • [[glDrawElements]]

Rasterisierung

Der Fragment Shader ist Teil der OpenGL Draw-Pipeline und wird für jedes Fragment aufgerufen. Er berechnet typischerweise:

  • Die Farbe des aktuellen Fragments und damit eines Pixels

Arbeitsweise

  • Vom Input gl_FragCoord kommt die Pixelposition (x, y) + Tiefe (z) des Fragments
  • In gl_FragDepth kann ein neuer Z-Buffer-Wert geschrieben werden
  • Der erste andere Output ist die Farbe, die auf dem Bildschirm landet

Beispiel

Dieser Fragment Shader bekommt die interpolierte Farbe vom Rasterizer und gibt sie anschließend aus:

in vec3 frag_col;
out vec4 out_color;
 
void main() {
	out_color = vec4(frag_col, 1);
}
 

Per-Fragment-Ops

Start

Shader Laden

  • [[glCreateProgram]]
  • [[glCreateShader]]
    • [[glShaderSource]]
    • [[glCompileShader]]
  • [[glAttachShader]]
  • [[glBindAttribLocation]]
  • [[glLinkProgram]]
  • [[glUseProgram]]

glBufferData - Sendet Daten an einen Buffer Binding Point.

void glBufferData (
	GLenum target, 
	GLsizei size, 
	const void *data, 
	GLenum usage
);
  • GLenum target - Der Zielport.
  • GLsizei size - Die Größe der zu sendenden Daten in Bytes.
  • const void *data - Zeiger auf die zu sendenden Daten.
  • GLenum usage - Hint an OpenGL, wie die Daten voraussichtlich verarbeitet werden, z.B.:
    • GL_STATIC_DRAW - Daten werden einmal auf die GPU hochgeladen und dann in jedem Frame gezeichnet DRAW
    • … (andere wenig relevant für CG)

Beispiel

glBufferData (
	GL_ARRAY_BUFFER,
	vertices.size() * sizeof(vec3),
	&vertices[0],
	GL_STATIC_DRAW
);

Daten an die GPU senden

  • [[glGenBuffers]]
  • [[glBindBuffer]]
  • [[glBufferData]]

glBindBuffer - Bindet einen Buffer Object an einen Buffer Binding Point.

void glBindBuffer(GLenum target, GLuint buffer)
  • GLenum target - Der Zielport. Beispiel:
    • GL_ARRAY_BUFFER - Zum Speichern von Vertex Arrays
    • 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 binden
glBindBuffer(GL_ARRAY_BUFFER , vbo);
 
// Jetzt können Daten an den Port geschickt werden, die dann im Buffer landen
 
// Port wieder entbinden
glBindBuffer(GL_ARRAY_BUFFER , 0);

Viewport

Vertex-Fetch

Primitive Assembly

Hier erst kommt der Index Buffer rein!

  • [[Indexed Face Set]]

Clipping

Fixed Function Vertex Processing

  • [[Perspektivisch korrekte Interpolation]]

Der Vertex Shader ist Teil der OpenGL Draw-Pipeline und wird für jeden Vertex aufgerufen. Üblicherweise wird hier berechnet:

  • Transformationen der Model-Verticies durch die verschiedenen Spaces

Ein Vertex Shader muss mindestens die Clip Space-Position des aktuellen Vertex in gl_Position zurück geben.

Arbeitsweise

  • Vom Input gl_VertexID kommt der Index des aktuellen Vertex
  • In den Output gl_Position kommt die Clip-Space Position des aktuellen Vertex
  • Andere Inputs mit können mit Input-Streams verbunden werden mit glBindAttribLocation

Interpolationsart einstellen

Alle Outputs werden an den Rasterisierer gegeben, der diese dann Interpoliert und an den Fragment Shader weitergibt.

Man kann die Art und Weise, wie interpoliert wird steuern, indem man vor out ein Keyword schreibt:

  • <Kein Keyword> - Interpoliert durch Baryzentrische Interpolation
  • flat - Kein Interpolieren, Wert kommt vom “provoking vertex” (meist erster Vertex des Dreiecks)
  • noperspective Interpolation durch Lineare Interpolation, ohne Perspektivkorrektur

Beispiel:

flat out vec3 frag_col; 

Beispiel

Dieser Vertex Shader transformiert einen Vertex vom Eyespace in den Clip Space und gibt die Farbe weiter an den Fragment Shader:

in vec3 local_vertex;
in vec3 color;
 
out vec3 frag_col;
 
uniform mat4 view;
uniform mat4 proj;
 
void main() {
	gl_Position = proj * view * vec4(local_vertex, 1.0);
	frag_col = color;
}
 

Framebuffer leeren

  • [[glClear]]
  • [[glClearColor]]

Shader binden

  • [[glUseProgram]]

glClear - Leert den aktuell gebundenen Framebuffer.

void glClear(GLbitfield mask);
  • GLbitfield mask - Welche Buffer geleert werden sollen. Optionen:
    • GL_COLOR_BUFFER_BIT - Der aktuelle Farbbuffer
    • GL_DEPTH_BUFFER_BIT - Der Depth Buffer
    • (GL_STENCIL_BUFFER_BIT - Der Stencil Buffer)

In OpenGL können wir Uniforms verwenden, um Daten für Shader bereit zu stellen, die über den gesamten Draw Call gleich sind. Kandidaten hiefür sind:

Uniforms laden

  1. Zuerst definieren wir Uniforms im Shader mit dem Schlüsselwort uniform:
uniform mat4 myUniformMatrix;
  1. Anschließend den Shader Laden
  2. Pro Frame dann das Shader Programm aktivieren
  3. Anschließend können wir mit glGetUniformLocation den Index der Variable auslesen
  4. An diesen Index senden wir dann die Uniform-Daten, z.B mit glUniform

Referenz

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 definieren
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
 
// vbo2 als zweiten Shader-Input definieren
glBindBuffer(GL_ARRAY_BUFFER, vbo2);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);

Referenz

Streamdefinitionen wiederverwenden

  • [[glGenVertexArrays]]
  • [[glBindVertexArray]]

glVertexAttribPointer - Aktiviert einen Input-Stream für den Vertex Shader.

void glEnableVertexAttribArray(GLuint index);
  • GLuint index - Der Index des Streams

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.

void glVertexAttribPointer (
	GLuint index,
	GLint size,
	GLenum type,
	GLboolean normalized,
	GLsizei stride,
	const void * data
);
  • GLuint index - Der Index des Streams
  • GLint size - Komponenten per Vertex
  • (GLenum type - Gibt den gespeicherten Datentyp an, meistens `GL_FLOAT)
  • (GLboolean normalized - Soll Normalisierung angewendet werden, meistens GL_FALSE)
  • (GLsizei stride - Lücke zwischen zwei Elementen in Bytes)
  • const void * data - Start-Offset in Bytes

Beispiel

// 
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);

Wir haben gesehen, wie man Vertex- und Index-Daten in GPU-Buffer schreibt und anschließend Streams an den Vertex-Shader definiert. Jetzt können wir die Rendering-Pipeline in Gang setzen. Das geht so:

Den Großteil (Clipping, Rasterisieren, …) erledigt OpenGL von selbst, allerdings können wir uns mit Shadern in bestimmten Abschnitten in bestimmte Phasen einklinken:

Beispiel

// vbo1 als ersten Shader-Input definieren
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
 
// vbo2 als zweiten Shader-Input definieren
glBindBuffer(GL_ARRAY_BUFFER, vbo2);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);
 
// ibo als Indizies nochmal binden, sofern nicht schon gemacht
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
 
// Draw Call!
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
 
// Bindungen lösen
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);

Referenz

glDrawArrays - Rendert Primitive aus Verticies, die direkt aus dem Vertex Array kommen.

void glDrawArrays(GLenum mode, GLint first, GLsizei count);
  • GLenum mode - Primitiver Typ zum Rendern
  • GLint first - Erster Vertex der genutzt werden soll
  • GLsizei count - Anzahl an Verticies die genutzt werden sollen

glDrawElements - Rendert Primitive aus Verticies, definiert durch ihre Indizies.

void glDrawElements (
	GLenum mode,
	GLsizei count,
	GLenum type,
	const void *indices
);
  • GLenum mode - Primitiv zu Rendern, z.B.:
    • GL_TRIANGLES - Dreiecke
    • 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
  • GLenum type - Typ des Index Buffers
  • const void *indices - Offset des Index-Pointers

Uniforms hochladen

  • [[glGetUniformLocation]]
  • [[glUniform]]

Eingabestreams definieren

  • [[glEnableVertexAttribArray]]
  • [[glVertexAttribPointer]]

glGetProgramiv - Gibt die Informationen eines Shader Programm zurück

void glGetProgramiv(GLuint program, GLenum param_name, GLint *values );

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:

  1. Buffer auf der GPU erstellen mit glGenBuffers
  2. Den Buffer an ein Ziel binden mit glBindBuffer
  3. Die Daten über Ziel an gebundenen Buffer senden mit glBufferData

Beispiel

// Die seien hier mal schon definiert
vector<vec3> vertices;
vector<unsigned> indices;
 
 
// 1. Buffer anlegen
GLuint vbo;
GLuint ibo;
glGenBuffers(1, &vbo);
glGenBuffers(1, &ibo);
 
// 2. Buffer an ein Ziel binden
glBind(GL_ARRAY_BUFFER, vbo); 
 
// 3. Daten über Ziel an Buffer senden
glBufferData(
	GL_ARRAY_BUFFER, 
	vertices.size() * sizeof(vec3), 
	&vertices[0], 
	GL_STATIC_DRAW
); 
 
// Bindung lösen
glBind(GL_ARRAY_BUFFER, 0); 
 
// Analog für den Index Buffer
glBindBuffer(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

Referenz

glGenBuffers - Erstellt ein Buffer Object auf der GPU.

void glGenBuffers(GLsizei n, GLuint *buffers);
  • GLsizei n - Die Anzahl der Buffer
  • GLuint *buffers - Eine Variable in die die GPU-Speicheradresse des Buffers geschrieben wird

Beispiel

GLuint vbo ;
glGenBuffers(1 , &vbo);
 
// Jetzt zeigt *vbo auf den Buffer

Problemstellung

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.

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:

Initial einmal:

  1. VAO generien mit glGenVertexArrays
  2. VAO binden mit glBindVertexArray
  3. Definieren der Streams wie wir es bereits gesehen haben
  4. VAO unbinden
  5. Buffer unbinden

Für den Draw-Call dann nur noch:

  1. VAO binden
  2. Den Draw-Call ausführen wie wir es bereits gesehen haben
  3. VAO unbinden

Beispiel

Initial einmal:

GLUint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);        // Ab hier wird aufgezeichnet
 
	glBindBuffer(GL_ARRAY_BUFFER, vbo1);
	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
	
	glBindBuffer(GL_ARRAY_BUFFER, vbo2);
	glEnableVertexAttribArray(1);
	glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);
	
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
	
glBindVertexArray(0);          // Aufzeichnung beendet
 
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);

Draw-Call:

glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);

Referenz

OpenGL Piping Data.svg

glGenVertexArrays - Generiert ein Vertex Array Object

void glGenVertexArrays(GLsizei n, GLuint *vaos);
  • GLsizei n - Anzahl der zu generierenden VAOs
  • GLuint *vaos - Array in das die generierten VAO Adressen gespeichert werden

glBindVertexArray - Bindet ein Vertex Array Object

void glBindVertexArray(GLuint array)
  • GLuint array - Adresse des zu bindenden VAO

glDeleteShader - Löscht einen Shader

void glDeleteShader(GLuint shader);

Ist ein Shader noch an ein Shader Programm gebunden, muss er erst noch mit glDetachShader gelöst werden.

glBindAttribLocation - Verknüpft einen Stream-Index mit einer Shader-Variable.

void glBindAttribLocation(GLuint program, GLuint index, const GLchar *name);
  • GLuint program - Der Index des Shader Programms
  • GLuint index - Der Index des Streams
  • const GLchar *name - Name der Shader Variable

Beispiel

glAttachShader(shader_prog, vert_shader);
glAttachShader(shader_prog, frag_shader);
 
// Stream an Index 0 wird mit "local_vertex" verknüpft
glBindAttribLocation(shader_prog, 0, "local_vertex");
 
glLinkProgram(shader_prog);

Im Vertex Shader:

in vec3 local_vertex;

glDetachShader - Löst einen Shader von einem Shader Programm

void glDetachShader(GLuint program, GLuint shader);

Ein Shader Programm fasst mehrere Shader zusammen. Es kann mit glCreateProgram erstellt werden.

Ein Shader ist ein Programm, das auf der GPU läuft. In Kontext von OpenGL sind Shader der programmierbare Part einer Rendering Pipeline.

glGetUniformLocation - Gibt den Index einer Uniform-Variable zurück.

GLint glGetUniformLocation(GLuint program, const GLchar *name);
  • GLuint program - Index des Shader Programms
  • const GLchar *name - Name der Uniform-Variabl

glUniform - Gibt den Wert einer Uniform-Variable an.

void glUniform{{type}}(GLint location, {{values}})
  • GLint location - Der Index der Uniform Variable.

Mit glUniform können verschiedenste Datentypen versendet werden. Beispiel:

glUniformMatrix4fv

glUniformMatrix4fv - GIbt den Wert eine Uniform-Variable vom Typ 4x4-Matrix an.

void glUniformMatrix4fv(
	GLint location, 
	GLsizei count, 
	GLboolean transpose,
  	const GLfloat *value
  );
  • GLint location - Der Index der Uniform Variable.
  • GLsizei count - Anzahl der zu übergebenden Matrizen
  • GLboolean transpose - Ob die Matrix transponiert werden soll
  • const GLfloat *value - Zeiger auf die Matrix

Beispiel

glUniformMatrix4fv(
	uniform_location("model"), 
	1, 
	GL_FALSE, 
	glm::value_ptr(mat4(1))
);

glClearColor - Füllt den Farb-Framebuffer mit einer Farbe auf.

void glClearColor(
	GLfloat red,
  	GLfloat green,
  	GLfloat blue,
  	GLfloat alpha
);
  • red, green, blue, alpha - Farbwerte. Wertebereich ist .
Link zum Original

Beleuchtung

Texturmapping

Shader

OpenGL Lighting und Textur Shader

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];
}
 
Link zum Original