Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 14 Min.

Licht und Schatten

Mit einem einfachen Beleuchtungsmodell lassen sich Szenen wesentlich realistischer gestalten.
Nachdem der erste Teil dieser Serie zu 3D-Grafikanwendungen mit .NET gezeigt hat, wie Sie ein dreidimensionales Dreieck und einen Würfel auf den Bildschirm bringen [1], erklärt der vorliegende Artikel mithilfe eines einfachen, aber sehr wirkungsvollen Beleuchtungsmodells, wie sich die Szene wesentlich realistischer darstellen lässt. So werden Texturen für die Oberfläche verwendet, dazu werden Sie tiefer in die Shader-Programmierung eintauchen und das Beleuchtungsmodell sowohl vertex- als auch fragmentbasiert implementieren. Dabei werden die sogenannten UV- und Normalenvektoren der dazugehörigen Vertices angegeben und an OpenGL geschickt. Anschließend ist eine Datenstruktur für ein einfaches Richtungslicht inklusive der entsprechenden Berechnungen zu erstellen.Auch wenn das noch böhmische Dörfer sind: Das Prinzip ist sehr einfach. Auch einige grundlegende Ideen kommen zur Sprache. Sämtliche Ressourcen, etwa die Textur, finden sich im Beispielprojekt zu diesem Artikel. Der Einfachheit halber geht es mit dem Dreieck aus dem ersten Beitrag weiter.

Shader revisited

Im ersten Artikel wurden Shader als Programme, die von der Grafikkarte ausgeführt werden, verwendet. Die Vertices (Vektoren) werden durch Vertex- und anschließend durch Fragment-Shader geschickt. Der Vertex-Shader übersetzt die Vertices mittels Matrizen in andere Koordinatensysteme, bis sie anschließend in einem Bereich zwischen -1 und 1 auf der X- und Y-Achse vorliegen und auf dem Bildschirm abgebildet werden können. Nicht im Sichtfeld befindliche Vertices werden dabei abgeschnitten.Der Fragment-Shader färbt die Flächen zwischen den Vertices ein. Der Entwickler programmiert letztlich die Shader und übermittelt Uniform-Variablen sowie Vertex-Buffer, die alle Informationen für die Transformation enthalten.

Texturen

Bisher ist das Dreieck optisch sehr einfach gehalten. Wer schon einmal Computerspiele gespielt hat, dem ist aufgefallen, dass Objekte texturiert sind; das bedeutet, dass die einzelnen Flächen eines Objekts mit einer Grafik überzogen wurden, mit einer sogenannten Textur. Dazu benötigen Sie neben dem Bild selbst sogenannte UV-Koordinaten. Letztlich handelt es sich dabei um Angaben in einem zweidimensionalen Koordinatensystem, das auf jeder Achse von 0 bis 1 reicht und eine Auswahl der Flächen in einer Textur ermöglicht.Da sich ein Polygon aus mehreren Vertices zusammensetzt, benötigen Sie zu jedem Vertex ein Set an UV-Koordinaten. Hierzu wird der Einfachheit halber ein weiterer Vertex-Buffer verwendet, der zu jedem Vertex die UV-Koordinaten enthält, die anschließend wie die Farbwerte aus dem ersten Artikel interpoliert werden. Ein kleiner Tipp: Sämtliche Koordinatenangaben (zum Beispiel Position, Farbe, UV) lassen sich auch in einen einzigen Vertex-Buffer packen und an OpenGL unter Angabe der Größe und Position der einzelnen Elemente im Buffer schicken.Die Textur selbst wird über eine Uniform-Variable in ein sogenanntes Sampler-Objekt im Fragment-Shader geladen. Dort kann das aktuelle Fragment unter Angabe der von  OpenGL interpolierten UV-Koordinaten in der Farbe der Textur an der jeweiligen Stelle eingefärbt werden. Wichtig ist auch, dass die Größe der Textur einem Exponenten von 2 (also etwa 2^8 = 256, 2^9 = 512) in Pixeln entsprechen sollte.

Materialien

Wenn in der Grafikprogrammierung von der Darstellung ­eines Objekts die Rede ist, kommt oft auch der Begriff „Material“ vor. Darunter versteht man eine Datenstruktur, die die Oberfläche des darzustellenden Objekts beschreibt. Für das hier verwendete Beleuchtungsmodell namens Phong (nach seinem Entwickler Bùi Tuòng Phong [2]) enthält die Datenstruktur Parameter für ambientes und diffuses Licht sowie Glanz, also die Reflexion, die letztlich das Beleuchtungsmodell komplett beschreiben. Weiterhin gilt eine Textur als Teil eines Materials.

Das Phong-Modell

Das Beleuchtungsmodell von Phong ist weit verbreitet, um ansprechende Oberflächenbeleuchtung darzustellen. Dabei ist es weder physikalisch korrekt noch effizient zu berechnen, aber dafür sehr einfach zu implementieren. Eine Alternative ist beispielsweise das Modell nach Blinn-Phong [3].Wie gerade erwähnt, teilt Phong die Beleuchtungskomponenten in ambientes, diffuses und Glanzlicht ein. Ambientes Licht beschreibt die Simulation von immer präsentem Licht: Stehen wir nachts in der Natur, ist nicht alles komplett schwarz, sondern wir sehen meist noch die Hand vor Augen – also Licht. Ambientes Licht verhindert, dass ein Objekt auf dem Bildschirm komplett schwarz dargestellt wird, falls es nicht unter dem Einfluss einer Lichtquelle steht.Diffuses Licht hingegen beschreibt den Einfluss des Lichts auf das Objekt. Dabei wird auf Basis des Einfallswinkels der Lichtstrahlen die Stärke der Beleuchtung berechnet. Je kleiner der Winkel ist, desto mehr Einfluss hat der Lichtstrahl auf die Färbung. Das heißt, dass das Grundleuchten (das ambiente Licht) je nach Einfall des Lichts immer heller wird.

Der Textur-Loader

Um Grafiken an OpenGL übermitteln zu können, sind mehrere Funktionalitäten nötig. Zuerst muss die Grafik von der Festplatte geladen werden. Die in diesem Beispiel verwendete Grafik wurde vom User FrozenStocks bei DeviantArt zur Verfügung gestellt [4]. Um die Textur an OpenGL übermitteln zu können, muss OpenGL wissen, dass eine Textur erstellt wird. OpenGL arbeitet intern mit einfachen ganzzahligen Identifikatoren, die es beim Erstellen von Objekten oder Identifikatoren generiert und an den Nutzer zurückgibt.Listing 1 enthält den Vorgang. Dazu muss die Textur mittels dieses Identifikators an ein bestimmtes Ziel, in diesem Fall GL_TEXTURE_2D, gebunden werden, damit OpenGL aufgrund seiner Natur als Zustandsautomat weiß, womit gerade gearbeitet wird. Die Daten müssen in ein BitmapData-Objekt kopiert werden. Anschließend können die Daten des Bildes inklusive des Bildformats (hier GL_BGR) an OpenGL übermittelt werden, das eine sogenannte MipMap generiert. Dabei handelt es sich um vorberechnete, verkleinerte Versionen der Textur, die OpenGL automatisch bei weiter entfernten Objekten verwendet. Zuletzt sollte die Bindung der Textur wieder rückgängig gemacht werden.
Listing 1: Eine Textur laden
<span class="hljs-title">private</span> void loadTexture() <br/>{ <br/>    <span class="hljs-type">OpenGL</span> gl = this.openGLControl.<span class="hljs-type">OpenGL</span>; <br/>    <span class="hljs-type">Bitmap</span> imageData = new <span class="hljs-type">Bitmap</span>(<span class="hljs-type">Path</span>.<span class="hljs-type">Combine</span>(<span class="hljs-type">Path</span>.<span class="hljs-type">GetDirectoryName</span>(<span class="hljs-type">System</span>.<span class="hljs-type">Reflection</span>.<span class="hljs-type">Assembly</span>.<span class="hljs-type">GetExecutingAssembly</span>().<span class="hljs-type">Location</span>), <span class="hljs-string">"texture.bmp"</span>)); <br/>    // <span class="hljs-type">Create</span> an object with the raw <span class="hljs-class"><span class="hljs-keyword">data</span> of the image </span><br/><span class="hljs-class">    <span class="hljs-type">System</span>.<span class="hljs-type">Drawing</span>.<span class="hljs-type">Imaging</span>.<span class="hljs-type">BitmapData</span> rawData = imageData.<span class="hljs-type">LockBits</span>(<span class="hljs-title">new</span> <span class="hljs-type">Rectangle</span>(0, 0, <span class="hljs-title">imageData</span>.<span class="hljs-type">Width</span>, <span class="hljs-title">imageData</span>.<span class="hljs-type">Height</span>), <span class="hljs-type">System</span>.<span class="hljs-type">Drawing</span>.<span class="hljs-type">Imaging</span>.<span class="hljs-type">ImageLockMode</span>.<span class="hljs-type">ReadOnly</span>, <span class="hljs-type">System</span>.<span class="hljs-type">Drawing</span>.<span class="hljs-type">Imaging</span>.<span class="hljs-type">PixelFormat</span>.<span class="hljs-type">Format24bppRgb</span>); </span><br/><span class="hljs-class">    // <span class="hljs-type">Create</span> the identifier and bind the texture </span><br/><span class="hljs-class">    gl.<span class="hljs-type">GenTextures</span>(1, <span class="hljs-title">textureHandle</span>); </span><br/><span class="hljs-class">    bindTexture(); </span><br/><span class="hljs-class">    // <span class="hljs-type">Submit</span> the raw <span class="hljs-keyword">data</span> to <span class="hljs-type">OpenGL</span> </span><br/><span class="hljs-class">    gl.<span class="hljs-type">TexImage2D</span>(<span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_TEXTURE_2D</span>, 0, (<span class="hljs-title">int</span>)<span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_RGB</span>, imageData.<span class="hljs-type">Width</span>, imageData.<span class="hljs-type">Height</span>, 0, <span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_BGR</span>, <span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_UNSIGNED_BYTE</span>, rawData.<span class="hljs-type">Scan0</span>);</span><br/><span class="hljs-class">    imageData.<span class="hljs-type">UnlockBits</span>(<span class="hljs-title">rawData</span>); </span><br/><span class="hljs-class">    imageData.<span class="hljs-type">Dispose</span>(); </span><br/><span class="hljs-class">    // <span class="hljs-type">Some</span> default texture filters </span><br/><span class="hljs-class">    gl.<span class="hljs-type">TexParameter</span>(<span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_TEXTURE_2D</span>, <span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_TEXTURE_MAG_FILTER</span>, <span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_LINEAR</span>); </span><br/><span class="hljs-class">    gl.<span class="hljs-type">TexParameter</span>(<span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_TEXTURE_2D</span>, <span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_TEXTURE_MIN_FILTER</span>, <span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_LINEAR</span>); </span><br/><span class="hljs-class">    // <span class="hljs-type">Generate</span> <span class="hljs-type">Mip</span>-<span class="hljs-type">Map</span> if needed </span><br/><span class="hljs-class">    gl.<span class="hljs-type">GenerateMipmapEXT</span>(<span class="hljs-type">OpenGL</span>.<span class="hljs-type">GL_TEXTURE_2D</span>); </span><br/><span class="hljs-class">    unbindTexture(); </span><br/><span class="hljs-class">} </span> 

UV-Koordinaten

Wie anfangs erwähnt benötigen Sie nun sogenannte UV-Koordinaten, um OpenGL mitzuteilen, welche Teile der Textur es für welche Flächen verwenden soll. Das UV- und Textur­-Koordinatensystem beginnt links unten und geht in X- und Y-Richtung bis 1.0f. Für jeden Vertex wird ein 2-Vektor (X, Y) benötigt. Die Erzeugung erfolgt genau wie bei allen anderen Attributen. Achten Sie darauf, OpenGL zu sagen, dass es sich um eine Datenstruktur aus zwei Float-Werten handelt und nicht aus dreien:
uvBufferObject.SetData(gl, <span class="hljs-number">2</span>, uvsToFill, false, <span class="hljs-number">2</span>); 

Das texturierte Dreieck zeichnen

Um die gebundene Textur darzustellen, ist sowohl eine Erweiterung der Render-Schleife als auch des Vertex- und des Fragment-Shaders vonnöten. In der Render-Schleife muss mittels glActiveTexture() zuerst ein sogenannter Texture Slot aktiviert werden (zum Beispiel GL_TEXTURE_0 für den ersten Slot). Dabei handelt es sich um eine Positionsangabe, um im Fragment-Shader mehr als eine Textur verwenden zu ­können. Anschließend müssen Sie mittels glBindTexture() den anfangs generierten Identifikator binden. Dies weist OpenGL darauf hin, dass die Daten (also die Textur), die im Speicher liegen und mittels des Identifikators zu finden sind, angewendet werden sollen. Verwenden Sie mehrere Texturen, sollten Sie mit glBindTexture(0) nach dem glDrawArrays()-Aufruf die Bindung der Textur aufheben.Der Vertex-Shader nimmt zuerst die UV-Koordinaten entgegen und gibt sie dann pro Vertex an den Fragment-Shader weiter. Dieser Vorgang nennt sich „Pass-Through“. Der Fragment-Shader wiederum muss die UV-Koordinaten explizit entgegennehmen. Weiterhin benötigen Sie das anfangs erwähnte Sampler2D-Objekt in Form einer Uniform-Variable.OpenGL hat nun alle Angaben, die es zum Zeichnen des Dreiecks mit einer Textur benötigt. Das Färben des Fragments geschieht im Fragment-Shader jetzt mittels eines einfachen Aufrufs von texture() mit dem Sampler-Objekt und den Texturkoordinaten. Wird das Programm kompiliert und gestartet, so zeigt es das erste Mal ein texturiertes Dreieck wie in Bild 1.
Zusammengefasst haben Sie das manuelle Einfärben der Fragmente (also der im Fragment-Shader zu behandelnden Pixel des Objekts) mittels an den Shader übermittelter und interpolierter Farbwerte letztlich ausgelagert und holen sich die Farbwerte ab sofort aus einem Bild auf der Festplatte.In der professionellen Grafikentwicklung können durchaus auch mehrere Texturen zum Einsatz kommen, die nicht nur zum Einfärben gedacht sind. So lässt sich etwa die Struktur von Objekten durch entsprechende Texturen verändern.Ein anderes populäres Verfahren verändert die Richtung der sogenannten Normalenvektoren, auf die der nächste Artikel dieser Serie eingehen wird. Diese werden – wie alle anderen Daten – ebenfalls per Vertex angegeben und anschließend für den Fragment-Shader automatisch interpoliert, also je Fragment berechnet, um etwa das Phong-Beleuchtungsmodell implementieren zu können.Mittels einer simplen Textur und ein wenig Mathematik lässt sich auf diese Weise für ein Objekt mit wenigen Polygonen eine Oberfläche simulieren, die wesentlich detaillierter wirkt, als es das eigentliche Modell hergibt. Wer sich für die Details interessiert, sei auf den Begriff „Normal Mapping“ hingewiesen.

Normalenvektoren

Zweimal ist bereits der Begriff des Normalenvektors gefallen. Die transformierten Vertices, die zum Zeichnen der Objekte in OpenGL angegeben werden, sind letztendlich im mathematischen Sinne Vektoren, die den „Weg“ von einem Startpunkt, beispielsweise dem Ursprung der hier verwendeten Koordinatensysteme, zu einem Ende beschreiben. Es handelt sich dabei nicht um explizite Positionsangaben.Wo ein Objekt letztendlich gerendert wird, wird durch die Angabe der Vertices in einem objektlokalen Koordinatensystem entschieden – multipliziert mit einer Transformationsmatrix (Verschiebung, Rotation, Skalierung), wieder multipliziert mit einer Kameramatrix (wie wird die Welt betrachtet?) und ein weiteres Mal multipliziert mit einer Projektionsmatrix (unter anderem für den Sichtwinkel), ergibt den dreidimensionalen Effekt.Ein Normalenvektor wird auf der durch mehrere Vertices aufgespannten Fläche definiert und steht senkrecht auf dieser. Dieser Vektor zeigt nun die Vorderseite der Fläche an – eine Information, die vorher nicht explizit vorhanden war und für eine korrekte Lichtberechnung unabdinglich ist.

Lichtquellen

Nachdem das Dreieck mit einer Textur versehen ist, kann es mit der eigentlichen Implementierung des Phong-Beleuchtungsmodells weitergehen. Insgesamt lassen sich mehrere verschiedene Typen von Lichtquellen in der Computergrafik finden. Die prominentesten Beispiele sind Richtungslichter, Punktlichtquellen und Spot-Lichter.Ein Richtungslicht kann am ehesten mit der Sonne verglichen werden: Es werden parallele Lichtstrahlen in eine Richtung geschickt; diese treffen auf einem Punkt auf. Anhand der Richtung und des (durch OpenGL für jedes Fragment interpolierten) Normalenvektors lässt sich dann der Einfallswinkel des Lichtstrahls bestimmen und somit die Stärke der Ausleuchtung jedes Fragments im Fragment-Shader ermitteln. Ein Richtungslicht hat, wie der Name schon sagt, nur ­eine Richtung und keine konkrete Position. Im Gegensatz zu Punktlichtquellen etwa vereinfacht das die Berechnung des Lichts erheblich und wird somit das Mittel der Wahl zur Demonstration von Beleuchtungen sein.Bevor es aber endlich mit der Programmierung losgeht, soll noch ein besonderes „Schmankerl“ der Shader-Programmierung erwähnt werden: der sogenannte Uniform-Block. Mittels einer Uniform-Struktur (struct) lassen sich Uniform-Variablen in einem Shader unter einem Namen zusammenfassen, also eine Datenstruktur erzeugen, die sich auch von C# aus ansprechen lässt. – passende Voraussetzung für die Defini­tion einer Datenstruktur für das Richtungslicht:
// C# 
struct DirectionalLight 
{ 
  public vec3 direction; 
  public float ambientStrength; 
  public vec3 ambient; 
  public vec3 diffuse; 
  public vec3 specular; 
}; 
DirectionalLight sun; 

// Fragment-Shader 
struct DirectionalLight { 
  vec3 direction; 
  float ambientStrength; 
  vec3 ambient; 
  vec3 diffuse; 
  vec3 specular; 
}; 
uniform DirectionalLight sun; 
Den Anfang bei der Berechnung des Beleuchtungsmodells macht die Angabe von Normalenvektoren je Vertex. Hierzu wird ein neuer Vertex-Buffer erzeugt, mit den Normalenvektoren gefüllt und an die Grafikkarte geschickt. Wie bei den Vertices handelt es sich um Vektoren aus drei Elementen, die in die X-, Y- und Z-Richtung zeigen. Das unten folgende Codebeispiel im Abschnitt „Ambientes Licht“ gibt eine Funktionalität zur Berechnung von Normalenvektoren aus drei Vertices vor. In diesem Artikel werden die Normalenvektoren aber der Einfachheit halber fest definiert.Die Normalenvektoren müssen nun zuerst vom Vertex-Shader in Empfang genommen und an den Fragment-Shader weitergeleitet werden. Dies erfolgt wie bei allen anderen Attributen auch mithilfe von In- und Out-Variablen. Dann wird mit der Implementierung aller drei Bestandteile des Phong-Beleuchtungsmodells fortgefahren.

Ambientes Licht

Die einfachste Komponente von Phong ist das ambiente Licht – das Umgebungsleuchten. In der Realität kommt das Licht nicht nur von einer Quelle, sondern liegt verstreut aus vielen verschiedenen Punkten vor. Genau so wird ein Grundleuchten des Dreiecks simuliert. Dies lässt sich einfach umsetzen, indem die Grundfarbe des Objekts, also die Textur, mit einer Lichtfarbe (hier Weiß) und einem Faktor für die Stärke des ambienten Lichts multipliziert wird:
vec3 getAmbient() { 
  vec3 color = sun.ambientStrength * sun.ambient; 
  return color; 
} 
... 
vec3 color = texture(textureSampler, passedUv).rgb; 
vec3 lightColor = getAmbient(); 
color = color * lightColor; 
outColor = vec4(color, 1.0); 
Die Werte werden aus dem C#-Programm unter Angabe der Variablennamen an den Shader übergeben und lassen sich variieren:
shader.SetUniform1(gl, "sun.ambientStrength",
  sun.ambientStrength); 
shader.SetUniform3(gl, "sun.ambient", sun.ambient.x,
  sun.ambient.y, sun.ambient.z); 
Das kompilierte Projekt zeigt das ambiente Licht. Spielen Sie ruhig ein wenig mit den Werten herum, um ein Gefühl für die unterschiedlichen Parameter zu bekommen (Bild 2).

Diffuses Licht

Nachdem die Implementierung des ambienten Lichts recht einfach war, sollen nun das erste Mal „richtige“ Berechnungen ins Spiel kommen. Aber keine Angst: Auch diese sind  eher einfach und dennoch sehr mächtig.Das diffuse Licht bezieht sich auf den Einfallswinkel des Lichts, der durch das Punktprodukt zwischen Normalenvektor eines Fragments und dem Richtungsvektor der Richtungslichtquelle abgebildet wird (Bild 3). Je größer der Einfallswinkel, desto weniger stark wird die Oberfläche beleuchtet. Das Punktprodukt nähert sich also 1.0 an, je kleiner der Einfallswinkel ist.
Da letztlich eine Multiplikation mit dem hier berechneten diffusen Faktor je Fragment erfolgt, stellt sich das Fragment je nach Einfallswinkel dunkler als andere dar. Die verwendeten Vektoren werden für die Berechnung normalisiert, sodass also die Summe aller Komponenten (X + Y + Z) genau 1 beträgt und somit alle Komponenten zwischen 0 und 1 liegen.Der Fragment-Shader bietet hierfür die Funktion normalize() an. Der Grund ist unter anderem, dass die Vektoren durchaus auch größere Werte annehmen können als 1.0 und dass dies Berechnungen verfälschen könnte; mit der Normalisierung von Vektoren ist man aber immer auf der sicheren Seite. Der folgende Code zeigt die Berechnung auf:
vec3 getDiffuse() { 
  vec3 normalizedNormal = normalize(passedNormal); 
  vec3 lightDirection = normalize(sun.direction
    - passedPosition); 
  // Calculate angle between normal and light ray 
  float diffuseAngle = dot(normalizedNormal,
    lightDirection); 
  // Clamp value 
  float diffuseFactor = max(diffuseAngle, 0.0f); 
  vec3 color = diffuseFactor * sun.diffuse; 
  return color; 
} 
Ihnen ist sicher aufgefallen, dass die Vertex-Positionen mit den Matrizen transformiert werden, die Normalen aber nicht. Das ist nicht unbedingt korrekt: Bei der Skalierung der Vertices des Dreiecks kann es zu unerwünschten Effekten kommen, da die Normalenvektoren nicht automatisch skaliert und somit verfälscht werden. Um die Normalenvektoren anzupassen, wird eine Normalenmatrix benötigt, die sich aus der übergebenen Modellmatrix errechnet.Weiterhin lassen sich Lichtberechnungen auch im View-Space, also dem Betrachtungsraum der virtuellen Kamera, anstellen. Die Implementierung in diesem Artikel bezieht sich auf den World Space, was zusätzliche Berechnungen zur Folge hat, aber intuitiver ist. Durch Transformation der Vektoren in den View Space, also durch Multiplikation mit der Kameramatrix, werden alle Vektoren relativ zum Ursprung der Kamera (0, 0, 0) ausgerichtet. Das erspart Berechnungen, allerdings würde es den Rahmen dieses Artikels sprengen.Nachdem nun alle Daten zum Berechnen des diffusen Lichteinfalls vorliegen, wird im Fragment-Shader eine kleine Funktion eingesetzt, die den diffusen Faktor je Fragment zurückgibt. Ein einfacher Aufruf dieser Funktion und die Multiplikation der bisherigen Fragmentfarbe mit dem Rückgabewert hat nun schon eine wesentlich realistischere Wirkung.Spielen Sie ein wenig mit den Werten herum, geben Sie eventuell einen zusätzlichen Faktor zur Berechnung der Stärke des diffusen Lichts mit in das Programm, um ein Gefühl für den Effekt zu entwickeln.

Reflexionsberechnung

Der Reflexionsfaktor ist ein schöner Effekt „on top“, der die Spiegelung einer Oberfläche abhängig vom Blickwinkel beschreibt. Wenn Sie Ihre Umgebung betrachten, so macht sich dieser Effekt in Form von weißen Flächen je nach Blickrichtung bemerkbar. Für den Artikel wird sich die Berechnung auf die Funktionalität für den diffusen Faktor stützen. Neben dem Normalenvektor wird dazu der Strahl von Lichtquelle zu Fragment benötigt. Mittels der Shader-Funktion reflect() lässt sich der Strahl nun perfekt reflektieren (Bild 4).
Da die Berechnung von der Blickrichtung abhängig ist, ist auch noch die Position des Betrachters nötig. Diese wird als Uniform-Variable in Form eines 3D-Vektors an den Shader übergeben. (In der nächsten dotnetpro-Ausgabe wird diese Position in Form einer Kamera veränderbar gemacht.) Weiter erhält der Shader noch eine Angabe zur Stärke der Reflex­ion. Um den Effekt zu berechnen, wird die Blickrichtung, also der Vektor zwischen Fragment und Betrachter, ermittelt und normalisiert. Sodann wird der Vektor zwischen Fragment und Lichtquelle am Normalenvektor reflektiert. Für die Reflexion wird der Vektor negiert, da für die Berechnung des diffusen Lichts der Vektor zwischen Lichtquelle und Fragment berechnet wurde und somit der Vektor vom Fragment zur Lichtquelle zeigt. Eine andere Möglichkeit wäre, die Berechnungen einfach umzukehren. Listing 2 zeigt die Standardberechnung der Reflexion in Bezug auf den Blickwinkel.
Listing 2: Die Berechnung des Reflexionseffekts
vec3 getSpecular() { &lt;br/&gt;  vec3 normalizedNormal = normalize(passedNormal); &lt;br/&gt;  // Calculate the difference between the viewers position and the fragment position, e. g. the ray between the current fragment and the viewer &lt;br/&gt;  vec3 viewDirection = normalize(cameraPosition - passedPosition); &lt;br/&gt;  // Reflect the view ray at the normal &lt;br/&gt;  vec3 reflectDirection = reflect(-viewDirection, normalizedNormal); &lt;br/&gt;  // Calculate the reflection factor of the fragment &lt;br/&gt;  // First calculate the angle between view ray and reflected ray &lt;br/&gt;  float specFactor = dot(viewDirection, reflectDirection); &lt;br/&gt;  // Then clamp it &lt;br/&gt;  specFactor = max(specFactor, 0.0f); &lt;br/&gt;  // Then pow it (^256) &lt;br/&gt;  specFactor = pow(specFactor, 256); &lt;br/&gt;  vec3 color = specFactor * sun.specular; &lt;br/&gt;  return color; &lt;br/&gt;}  
Der Glanzwert je Fragment ergibt sich nun aus dem Punktprodukt, also dem Winkel zwischen Blickrichtung und Reflexionsrichtung. Dieser darf nicht negativ sein, wird also auf ­einen Wert ab 0.0 fixiert. Dies erfolgt durch die Verwendung der max()-Funktion, die den größeren der beiden übergebenen Werte zurückgibt; mittels pow() lässt sich der Faktor dann exponentiell verstärken. Eine simple Multiplikation der bisherigen Werte mit dem Glanzfaktor ergibt nun je nach Einfallswinkel eine Spiegelung, die sich beispielsweise für die Darstellung metallischer Objekte eignet. Auch hier gilt: Spielen Sie mit den Werten und bekommen Sie ein Gefühl für die Berechnungen (Bild 5).

Exkurs: Effizienz beim Rendering

Beim Phong-Beleuchtungsmodell handelt es sich um ein sehr einfaches Modell, das aber trotzdem sehr schöne und realitätsnahe Effekte liefern kann. Wie Sie sich aber sicher vorstellen können, ist die Berechnung je Fragment für die Grafikkarte recht aufwendig. Je nach Auflösung ergeben sich Tausende und Millionen von Berechnungen. Dass bei diesem Vorgehen nicht unbedingt viele Lichtquellen auf dem Bildschirm darzustellen sind, die sich auch noch gegenseitig beeinflussen, kann man sich wohl vorstellen.Glücklicherweise sind Sie Entwickler und können sowohl die Art als auch die Reihenfolge des Renderings bestimmen. OpenGL bietet von Haus aus den Z-Buffer, in dem die Tiefenwerte des aktuellen Fragments mit den Werten der vorher geschriebenen Fragmente verglichen werden. Hat das aktuell zu schreibende Fragment (standardmäßig) einen kleineren Wert als der Wert, der im Z-Buffer steht, so wird das Fragment geschrieben und der Z-Buffer aktualisiert. Wenn nicht, dann nicht. Wollen Sie nun mehrere Objekte rendern, dann bestimmen Sie die Reihenfolge so, dass Objekte, die sich gegenseitig verdecken, von hinten nach vorne gezeichnet werden. Das kann durch Sortierung erfolgen.
Weiterhin lässt sich das sogenannte Frustum Culling anwenden. Diese Methode prüft die Vertices der zu zeichnenden Objekte gegen die virtuelle Kamera – also das, was auf dem Bildschirm tatsächlich zu sehen ist – und verhindert eventuell, dass die Objekte an die Grafikkarte übermittelt werden. Dort passiert zwar auch ein Culling, also das Verwerfen oder Zurechtschneiden von Objekten, es belastet bei ­einer Vielzahl von Objekten jedoch die Grafikkarte wiederum stark, da gegebenenfalls Tausende von Objekten geprüft werden müssen, obwohl eigentlich von vornherein klar ist, dass diese nicht sichtbar sind.In der professionellen Spieleentwicklung etwa kommen sowohl sogenannte Lightmaps als auch oft Deferred Rendering zum Einsatz. Bei den Lightmaps handelt es sich um vorkalkulierte Texturen, die die Beleuchtung eines Objekts anzeigen. Das Erstellen von Lightmaps nennt sich Baking und geschieht heute durch Grafik-Engines automatisch. Es ist eine Wissenschaft für sich, die aber sehr gute Ergebnisse liefert und sehr effizient ist.
Beim Deferred Rendering [5] werden nicht die Objekte an sich Stück für Stück gerendert und beleuchtet, sondern es wird zuerst die Geometrie (die Vertices) gerendert und dann in unterschiedlichen Schritten die Szene eingefärbt, etwa anhand der Texturinformationen. Das Besondere hierbei ist, dass die Beleuchtung nun ebenfalls in einem Rutsch berechnet wird. Das ermöglicht das effiziente Implementieren von Tausenden von Lichtquellen in einer Szene, die sich gegenseitig beeinflussen.Gewisse mathematische Aktivitäten sind für die GPU – ebenso wie für die CPU auch – sehr aufwendig, vor allem, wenn diese für jedes Fragment erfolgen. Dazu gehört zum Beispiel die Multiplikation von Matrizen oder Exponenten, wie hier für die Reflexion. Wenn möglich, sollten Werte durch das Programm vorab berechnet und dann anschließend an die Grafikkarte übermittelt werden.

Fazit

Das Implementieren von Texturierung und Beleuchtung ist in den Grundzügen sehr einfach und bildet ein gutes Fundament für das Verständnis der Shader-basierten Grafik­entwicklung. Ich möchte Sie ermuntern, auf Basis der vorhandenen Implementierung selbst zu experimentieren und sich mit den dahinter liegenden Zusammenhängen auseinanderzusetzen.Im nächsten Artikel werden Sie sich mit der Implementierung einer virtuellen Kamera und der Darstellung zweidimensionaler Objekte im dreidimensionalen Raum beschäftigen. In der Spieleentwicklung lassen sich so etwa Lebens- oder Mana-Anzeigen realisieren. Die Funktion ist auch für die vereinfachte Darstellung weit entfernter Objekte, die nicht mit der kompletten Geometrie gerendert werden müssen, verwendbar. Damit besitzen Sie dann ein gutes Fundament, um eigene Grafikanwendungen zu erstellen.

Fussnoten

  1. Thomas Symalla, Vom Punkt zum Würfel, 3D-Grafikanwendungen mit .NET, Teil 1, dotnetpro 5/2018, Seite 118 ff., http://www.dotnetpro.de/A1805Grafik
  2. Wikipedia, Phong-Beleuchtungsmodell, http://www.dotnetpro.de/SL1806Grafik1
  3. Wikipedia, Blinn-Beleuchtungsmodell, http://www.dotnetpro.de/SL1806Grafik2
  4. Deviant Art, Grass Texture, http://www.dotnetpro.de/SL1806Grafik3
  5. DGL Wiki, Deferred Shading, http://www.dotnetpro.de/SL1806Grafik4

Neueste Beiträge

DWX hakt nach: Wie stellt man Daten besonders lesbar dar?
Dass das Design von Websites maßgeblich für die Lesbarkeit der Inhalte verantwortlich ist, ist klar. Das gleiche gilt aber auch für die Aufbereitung von Daten für Berichte. Worauf besonders zu achten ist, erklären Dr. Ina Humpert und Dr. Julia Norget.
3 Minuten
27. Jun 2025
DWX hakt nach: Wie gestaltet man intuitive User Experiences?
DWX hakt nach: Wie gestaltet man intuitive User Experiences? Intuitive Bedienbarkeit klingt gut – doch wie gelingt sie in der Praxis? UX-Expertin Vicky Pirker verrät auf der Developer Week, worauf es wirklich ankommt. Hier gibt sie vorab einen Einblick in ihre Session.
4 Minuten
27. Jun 2025
„Sieh die KI als Juniorentwickler“
CTO Christian Weyer fühlt sich jung wie schon lange nicht mehr. Woran das liegt und warum er keine Angst um seinen Job hat, erzählt er im dotnetpro-Interview.
15 Minuten
27. Jun 2025
Miscellaneous

Das könnte Dich auch interessieren

UIs für Linux - Bedienoberflächen entwickeln mithilfe von C#, .NET und Avalonia
Es gibt viele UI-Frameworks für .NET, doch nur sehr wenige davon unterstützen Linux. Avalonia schafft als etabliertes Open-Source-Projekt Abhilfe.
16 Minuten
16. Jun 2025
Mythos Motivation - Teamentwicklung
Entwickler bringen Arbeitsfreude und Engagement meist schon von Haus aus mit. Diesen inneren Antrieb zu erhalten sollte für Führungskräfte im Fokus stehen.
13 Minuten
19. Jan 2017
Evolutionäres Prototyping von Business-Apps - Low Code/No Code und KI mit Power Apps
Microsoft baut Power Apps zunehmend mit Features aus, um die Low-Code-/No-Code-Welt mit der KI und der professionellen Programmierung zu verbinden.
19 Minuten
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige