WebGL Experiments

Fakten
TypTechnologie Evaluierung
PlattformBrowser
EntwicklerBenjamin Granzow
StatusAbgeschlossen
TechnologienTDL
Ashima webgl-noise
gl-Matrix Version 2.2.0
webgl-utils

Konzept

Nativ die Grafikkarte, aus dem Browser, zu anzusprechen bietet ganz neue Möglichkeiten für interaktive online Applikationen. Diese neue Umgebung bietet dabei auch einige Einschränkungen. Mittels der nachfolgenden Experimenten sollen de Besonderheiten und deren Verwendbarkeit von komplexen optischen Effekten erprobt werden.

Untersuchungsgegenstand

  • Shader-Programmierung, Geometry, Vertex, und Fragment-Shader
  • Multi-Pass Rendering, Post-Processing
  • Tesselation and Terrain-Generation
  • Global Illumination, Schatten, Spiegelungen
  • Image-Based Rendering
  • Non-Photorealistic Rendering
  • Mehrfach-Texturierung

Tesselation

Die Geometrie einer Kugel wird durch das rekursive Unterteilen(Tesselation), ausgehend von einem Tetraeder, hergestellt. Dabei werden die Farben durch die Koordinaten der Punkte bestimmt.

Einfachheitshalber wird nur eine Seitenfläche des Tetraeders betrachtet. Bei der Tesselierung geht es darum, durch Hinzufügen neuer Punkte die Orginalgeometrie besser zu approximieren. Die neuen Punkte werden zwischen die vorhandenen Punkte auf die Kanten des ursprünglichen Dreieckes gelegt. Dieser Zustand ist in Abbildung 1 auf der linken Seite zu erkennen. Um die neuen Punkte an die Kreisgeometrie anzupassen, können diese Punkte normiert werden. Die Verschiebung der Punkte ist in Abbildung 1 auf der Rechten Seite dargestellt. Dieses ist aber nur dann ausreichend, wenn die Kugel einen Radius von 1 besitzt (Einheitskugel). Da Objekte jederzeit beliebig skaliert werden können, kann durch die Ausnutzung der Normierungseigenschaft einfach der Punkt auf den Radius verschoben werden.

Um eine beliebig genaue Approximation zu erhalten, kann dieser Algorithmis rekursiv für jedes neue Dreieck aufgerufen werden, zB. für die Punkte (V1, S2, S3).

  • Links: Tetraeder Seitenfläche mit den ursprünglichen Punkten(v) und den neu Errechneten(s) Rechts: Die neuen Punkte(s) wurden durch Normierung auf Kugeloberfläche verschoben(Einheitskugel)

Demonstration


Tastenbelegung

W – switch view (wireframe, solid)
Q – decrement tessellation level (min. 0)
E – increment tesselalation level (max. 6)

 

Simple Lighting

Toon Shader

Beim Toon Shader geht es darum, den Verlauf der Lichthelligkeit einer Lichtquelle stufenweise zu approximieren. Dazu müssen die fein abgestuften Werte aus dem Skalarprodukt zwischen Lichtrichtung und Betrachterrichtung auf die nächstliegende Stufe abgebildet werden. Dadurch wird eine starke Abstufung erziehlt.

Um den Cartoon-Effekt zu erhalten, so dass die Kanten dick überzeichnet sind, muss nur das Skalarprodukt zwischen Normale und Betrachterrichtung ausgewertet werden. Wenn dieser einen sehr kleinen Wert annimmt, schaut der Betrachter fast rechtwinklig auf die untersichte Stelle. Diese Kante wird dann einfach schwarz eingefärbt, um einen Rand zu erhalten.

  • Diskretisierung des Skalarproduktes

Prozedurale Textur

Damit eine Textur in eine beliebigen Anzahl von einzelnen Flächen unterteilt wird, kommt die Modulo-Funktion zum Einsatz. Dieses ist soweit ausreichen, da in jeder Teilfläche das selbe geschehen soll. In diesem Beispiel wird jede Fläche mit einem farbigen Punkt versehen. Die Position des untersuchten Punktes wird sich immer im Bereich [0.0, stepSize] befinden. Um herauszufinden ob der Punkt sich innerhalb des Kreises befindet, kann alternativ zur Kreisformel auch die Eigenschaften von Vektoren ausgenutzt werden. Bildet man einen Vektor vom Punkt zum Kreismittelpunkt, so kann die Länge des resultierenden Vektors verwendet werden, um zu validieren, ob der Punkt innerhalb des Kreises liegt oder nicht. Um die Größe des Kreises zu bestimmen, kann einfach ein Wert von [0.0,1.0] aus der Application übergeben werden. Wird diese Kreisgröße mit der halben StepSize mutipliziert, repräsentiert diese wieviel Prozent des Teilfeldes vom Kreis eingenommen werden sollen. Vergleicht man die Länge des Vektors mit der umgerechneten Kreisgröße und ist diese gleich oder kleiner, befindet sich der Punkt innerhalb des Kreises und kann eingefärbt werden.

  • Bewertung ob ein Punkt p inerhalb eines Radiuses r einer Kugel ist. Durch den vergleich von Vektorlängen.

Proceduraler Shader

Durch einen prozeduralen Anteil im Vertex Shader ist es möglich, wenn man die aktuelle Zeit mit übergibt, das Model zu animieren. In diesem Fall wurde auf die Noise-Funktion von Ashima webgl-noise zurückgegriffen. Die Idee besteht darin, jeden Vertex in Richtung seiner Normalen um den Noise Faktor zu verschieben. In der nachfolgenden Abbildung wird dieses verdeutlicht. Damit sich diese auch über der Zeit verändert, wird für die Noise-Funktion die Zeit verwendet um die Aussteuerung der Funktion zu steuern.

  • Noising einer Kugel um einen Faktor.

Demonstration

Tastenbelegung

0 – Default Shader
1 – Toon Shader (Torus)
2 – Prozedurale Textur (Cube)
3 – Prozeduraler Shader (Sphere)

S – stop rotation
Q – decrement point segmentation (min. 1)
E – increment point segmentation (max. 10)

 

Environment Mapping

Kamera

Für die Steuerung wurde eine eigene Kamera-Klasse erstellt. Diese bietet nicht nur standard Funktionalitäten, sondern auch eine einfache Erstellung und Integration in das TDL-Framework. Die Kamera bietet Methoden, um die Ausrichung und Position der Ansicht zu verändern. Für die Vor-/Zurückbewegung wird die Position in Richtung der Kameradirektion verschoben. Bei den seitlichen Bewegungen wurde auf die Eigenschaft des Kreuzproduktes zurückgegriffen, um den seitlichen Verschiebungsvektor zu erhalten. Da der Direction- und der Up-Vektor orthogonal zueinander stehen, ergibt ihr Kreuzprodukt einen Vektor der orthogonal zu beiden steht, also rechtwinklig zur Blickrichtung ist. Abhänig von der Reihenfolge des Kreuzproduktes ist dieser auf der linkt oder rechten Seite. Verwendet man diesen resultierenden Vektor, wird die Kamera seitlich verschoben.

Für die Kamerarotation wird die Position der Maus verwendet, um die Direktion und damit die Blickrichtung anzupassen.

Environment Mapping

Bei dem Environment-Mapping geht es in diesem Beispiel darum, dass ein Objekt seine Umgebung perfekt reflektiert. Für die Darstellung der Umgebung wurde auf eine Skybox zurückgegriffen. Diese besteht aus einem Würfel, bei welchem die Normalen(Oberflächenorientierung) umgedreht wurden. Dadurch wird dieser von innen sichtbar. Für die Texturierung wurde die textureCube Methode verwendet, welche in GLSL unterstützt wird. Dabei werden dem Shader nur die Sechs Texturen der Würfelseiten gegeben und dieser erstellt selbständig die passende Darstellung.

Für die darin liegenden Objekte muss die Oberflächenfarbe dem Environment entnommen werden. Dafür wird die Blickrichtung des Betrachters(v) an der Normalen(n) des Objektes reflektiert. Diese Reflektion wird anschliessend erneut verwendet, um die Farbinformation aus der Cube-Map zu lesen. Diese Methode wird in der nachstehenden Abbildung verdeutlicht.

  • Spiegelung an einer Kugel.

Normal Mapping

Gänigerweise wird Normal Mapping dazu verwendet die Berechnung von Licht zu beeinflussen, damit Objekte mehr Plastizität erhalten. In diesem Beispiel wird die Normal-Map dazu verwendet, die Reflektion der spiegelnden Objekte zu beeinflussen. Der Grundgedanke dabei ist, die normale Normale eines Objektes durch eine beliebige, zB. aus einer Textur, auszutauschen. Da die Normalen objektabhänig sind, muss eine Änderung auch in diesem Koordinatensystem vollzogen werden. Dafür müssen alle benötigten Informationen in den sogenannten Tangenten-Raum umgerechnet werden. Dieses passiert durch eine Tangenten-Bitangenten-Normalen-Matrix(TBN). Diese besteht aus der aktuellen Tangente, Bitangente und Normalen der Position welche spaltenweise zu einer Matrix zusammengefügt werden.

Durch diese Matrix können andere Vektoren in denn Tangentenraum überführt werden, wie in diesem Beispiel die Betrachterrichtung. Anschliessend wird die neue Normale aus einer Textur gelesen. Mittels dieser neuen Normalen kann eine veränderte Reflektion errechnet werden, welche diesesmal mit der überführten Betrachterichtung geschieht. Doch muss darauf geachtet werden, dass bevor die Farbinformation aus der Cube-Map gelesen wird, die neu errechnete Reflention mit “ -TBN * reflection “ in das Weltkoordinatensystem zurück überführt wird. Anschliessend kann der neue Farbwert einfach aus der Cube-Map gelesen werden.

  • Zusammensetzung der TBN-Matrix

Demonstration

Tastenbelegung

1 – Disable Normal Mapping
2 – Enable Normal Mapping

WASD – Move the Camera
Click+Move Mouse – Rotate Camera

 

Dynamic Reflection/Refraction

Eigenschaften von Wasser

Wenn man Oberflächen von Wasser beobachtet, fallen folgende Eigenschaften auf:

  • Wellen
  • Reflektion (reflection)
  • Brechung (refraction)
  • Absorbtion
  • Brennlinien (caustics)

In diesem Beitrag wird auf die Umsetzung der ersten drei Eigenschaften eingegangen und als Technik auf Multipass-Rendering zurückgegriffen.

Wellen

Um eine Oberfläche wie Wasser erscheinen zu lassen, sollten Wellen verwendet werden. Die einfachste Möglichkeit Wellen umzusetzen, besteht darin, die Lichtberechung der Oberfläche durch eine Normalmap wellenförmig erscheinen zu lassen.

Fragment Shader

varing vec2 texCoordI;
varing mat4 TBN;
uniform float elapsedTime;
uniform sampler2D water_normal;
…
vec2 moveTexCoord = texCoordI + vec2(1.0) * elapsedTime * waterSpeed;
vec3 normalN = TBN * decodeNormal(texture2D(water_normal, moveTexCoord).rgb);
…
gl_FragColor = phong (…, normalN, …);

Leider erzeugt die Umsetzung der Bewegung und “Höhe” durch eine Normalmap nur eine Illusion der drei Dimensionalität, welche an den Rändern der Oberfläche zusammenbricht, da die Oberfläche in Wirklichkeit nicht so geformt ist. Dieses ist in der linken Darstellung zu sehen.

Um dieser Problematik entgegenzuwirken, kann zusätzlich zur Beleuchtung durch “Vertex displacment” die Geometrie angepasst werden. Dafür wird im Vertex Shader die Informationen in der Normalentextur verwendet, um den Vertex auf der Y-Achse zu verschieben.

  • Wasserfläche mit bewegender Normalmap.

  • Vertex-Verschiebung entlang der y-Achse, abhänig von einer Nomal-Map.

  • Scene mit vertex displacement und Normal Mapping.

Wie in der mittleren Darstellung gezeigt, geht es darum, aus den Informationen der Normal-Map die Höhe neu zu setzen. Dafür kann eine Paralax-Mapping Textur verwendet werden. Diese besitzt als Besonderheit, dass im Alpha-Kanal der Textur die Höhe hinterlegt ist. Die Höhe ist im Wertebereich [0.0, 1.0] angegeben. Mit einem Skalierungsfaktor lässt sich die Wellengröße dadurch beliebig anpassen. Dabei ist zu beachten, dass die Bewegung der Textur genauso mitberechnet wird wie im Fragment Shader, damit die Beleuchtung zu den Wellen passt. Für Anpassung der Geometrie sind damit nur ein paar zusätzliche Berechnungen im Vertex Shader notwendig. Das resultat ist in der rechten Darstellung abgebildet.

Vertex Shader

attribute vec2 texCoord;
uniform float elapsedTime;
uniform sampler2D water_normal;
…
positionW = model * position;
vec2 moveTexCoord = texCoord + vec2(1.0) * elapsedTime * waterSpeed;
positionW.y += (2.0 * texture2D(water_normal, moveTexCoord).a – 0.5) * waveHeight;
…
gl_Position= projection * view * positonW; 

Reflektion (reflection)

Für die Reflektion stehen einem die herkömmlichen Formeln aus dem Bereich der Vektorrechnung zur Verfügung. Doch da für die Umsetzung der Multipass Ansatz verwendet wird, gibt es eine einfachere Möglichkeit, diese umzusetzen. Damit nicht aufwändig die Reflektion berechnet werden muss, kann man die Szene aus der reflektierten Ansicht rendern. Der naive Ansatz dafür wäre, die Spiegelebene in den Nullpunkt zu verschieben und die Kamera an dieser zu Spiegel. Um aber die Manipulation der View-Matrix zu verhindern, kann stattdessen auch die Welt gespiegelt werden. Dieses ist sehr einfach umzusetzen, indem die Y-Position des Vertexes in der Welt einfach * -1 gerechnet wird, um diese zu spiegeln.
Die nachfolgende Darstellung stellt diese Methode dar.

Vertex Shader

…
if(renderReflection) {
positionW.y *= -1.0;
}
…
  • Prinzip der Spiegelung, durch Invertierung der Welt.

Clipping

Da für die Reflektion NUR die Spiegelung benötigt wird, ist es notwendig, alle Objekte ausserhalb der Spiegelfläche abzuschneiden, so dass nur die reine Reflektion übrig bleibt. Unter OpenGL würde in solchen Fällen auf Clipping-Planes zurückgegriffen werden. Diese Funktionalität ist in WebGL nicht vorhanden, so dass alle überflüssigen Bereiche selbstständig entfernt werden müssen.

Zu diesem Zweck gibt es im Fragment Shader eine Funktion namens “discard”. Diese sorgt dafür, dass der Shader das aktuelle Fragment verwirft und somit aus der Szene entfernt. Nachstehender Code entfernt alle Fragments ausserhalb der Spiegelfläche, welche im Ursprung liegt.

Vertex Shader

…
if(renderReflection && positionW.y >0.0) {
discard;
}
…

Anschliessend steht die fertige Spiegelung im Framebuffer zur Verfügung. Zusammenfassend kann man den Reflections-Pass in folgende Schritte unterteilen:

  1. Szene ohne Spiegelfläche (Wassser) rendern
  2. Im Vertex Shader an der xz-Achse spiegeln
  3. Objekte ausserhalb der Spiegelfläche entfernen (Clipping)

 

  • Prinzip des Clipping, an der Spiegelfläche, bei der Reflektion.

  • Gespiegelte Szene im Framebuffer.

Brechung (refraction)

Brechung von Wasser zu berechnen, ist etwas aufwändiger als die Spiegelung, da dieses nicht auf der Grafikkarte geschehen kann, ohne unnötig Rechenleistung zu verbrauchen. Aus diesem Grund wird die Brechung pro Frame auf die View-Matrix aufgerechnet, statt für jeden Vertex einzeln im Shader. Da die Fläche aber eine beliebige Position einnehmen kann, ist es nicht einfach, die Rotationsachse zu bestimmen. Denn diese ist vom Betrachter (Position, Blickrichtung) und der Position der Ebene abhänig.

Um sicher eine gültige Rotationsachse zu erhalten, muss zuerst der Schnittpunkt mit der Ebene berechnet werden. Ausgehend von diesem Punkt wird ein weiterer Ebenenvektor in Blickrichtung des Betrachters benötigt. Zusammen mit dem Normalenvektor der Ebene, welcher orthogonal zum Ebenenvektor steht, kann durch das Kreuzprodukt dieser beiden ein dritter Vektor errechnet werden, der orthogonal zu den beiden Anderen ist. Dieser ergibt dann die benötigte Rotationsachse. In der linken Darstellung ist diese Herangehensweise verdeutlicht.

Den Schnittpunkt eines Vektors und eine Ebene zu berechnen, ist durch die Hessesche Normalenform und der Strahlengleichung möglich. Als Werte für den Strahl werden die Kameraposition und Blickrichtung verwendet. In der rechten Darstellung sind die Formeln noch einmal verdeutlicht.

  • Normale der Ebene (Gelb), Ebenenvektor (Grün), Rotationsachse (Rot)

  • Erläuterung der Hessischen Normalenform.

Die Umsetzung findet in der Kamera-Klasse statt und ergibt am Ende eine verschobene View-Matrix.

Camera.js

Camera.prototype.createRefraction = function(refPlane){
…
var denuminator= vec3.dot(refPlane.normal, camera.direction);
if(denuminator == 0){
return;
}
t = refplane.distance – vec3.dot(camera.position, refPlane.normal ) / denuminator;
if(t < 0){ return; } var intersection = vec3.add(camera.position, vec3.scale(camera.direction, t, []), []);

Um den Ebenenvektor zu erstellen, muss die Ebene in der Normalengleichung dargestelt werden. Diese Darstellung erlaubt es, mit einem bekannten Vektor o einen beliebigen Punkt x auf der Ebene zu bestimmen. Da dieser Punkt aber in Blickrichtung liegen soll, kann man einfach auf den Schnittpunkt die xz-Komponente der Blickrichtung aufaddieren. Dadurch erhält man einen Vektor, der vom Schnittpunkt in Blickrichtung verschoben ist, aber nicht auf der Ebene liegt. Um dieses wieder herzustellen, muss die passende Höhe des Punktes bestimmt werden.

Multipliziert man die Normalengleichung aus, kann man den zweiten Term in n-o = m substituieren, da diese Werte bekannt sind. Wird anschliessend diese Formel in die Koordinatengleichung umgeformt, bleibt dort nur noch der mittlere Term mit der gesuchten Variablen y übrig, zu der einfach umgeformt werden kann.

Durch die so bestimmte Höhe ist der Ebenenvektor vollständig und kann nun verwendet werden, um mit dem Kreuzprodukt und der Ebenen-Normalen die Rotationsachse zu bestimmen.

…
var dirAligPoint = vec3.add(intersection, vec3.create([camera.direction[0], 0.0, camera.direction[2]]), []);
var m = vec3.dot(refPlane.position, refPlane.normal);
var y = (-refPlane.normal[0] * dirAligPoint[0] - refPlane.normal [2] *
dirAligPoint[2] - m) / -refPlane.normal [1];
dirAligPoint[1] = y;

var rotAxis = vec3.normalize(vec3.cross(refPlane.normal, dirAligPoint, []));

Danach kann dieser Vektor als Rotationsachse verwendet werden, um die View-Matrix um 1,33° zu drehen und die Szene zu rendern.
Anschliessend steht die fertige Brechung im Framebuffer zur Verfügung. Zusammenfassend kann man den Brechungs-Pass in folgende Schritte unterteilen:

  1. View um 1.33° rotieren
  2. Szene ohne Spiegelfläche (Wassser) rendern
  3. Objekte außerhalb der Spiegelfläche entfernen (Clipping)
  • Gebrochene Szene im Framebuffer.

Texture Mapping

Nachdem alle Informationen in dem Framebuffer vorgerendert wurden, muss nur noch die endgültige Szene gerendert werden und auf die Wasserfläche die Texturen der Reflektion und Brechung aufgetracht werden. Nur muss dabei beachtet werden, dass die Texturen perspektivisch korrekt auf der Oberfläche dargestellt werden. Wurden anfänglich auch noch die Wellen mit Vertex-displacement erstellt, muss zusätzlich noch eine Versatzberechung duch die Welle hinzugefügt werden.

Dafür müssen die Wellenpositionen nur in den Screen-space [-1, 1] konvertiert werden. Bei der Versatzberechnung muss darauf geachtet werden, dass diese bei der Reflektion addiert und bei der Brechung subtrahiert wird. Anschliessend werden die Farbwerte an diesen Koordinaten aus den Texturen ausgelesen.

vec2 screen = (positionI.xy / positionI.w + 1.0) * 0.5;

float dist = length(eyePosition – positionW.xyz);
float distortFac = max(dist / 100.0, 10.0);
vec2 distortion = normalN.xz / distortFac;
reflectionSample = texture2D(reflectionColorBuffer, screen + distortion);
refractionSample = texture2D(refracionColorBuffer, screen - distortion);

Für die Berechnung der Anteile der Reflektion und der Brechung wird normalerweise die Fresnelsche Formel verwendet. Diese beschriebt ganz genau, wie groß die verschiedenen Anteile sind, wenn sich Licht durch die Grenze zweier Materialien mit unterschiedlichen Materialkoeffizienten bewegt.
Einfachheitshalber wurde bei dieser Umsetzung dafür das Skalarprodukt von Betrachterposition und der Oberflächennormalen verwendet. Sind diese beiden normiert, ergibt sich der Cosinus den Winkels zwischen den beiden Vektoren. Dieser kann dafür verwendet werden, die beiden Texturen zu interpolieren. Dabei ergibt sich automatisch der Effekt, dass wenn der Winkel zu flach wird, dass fast nur Reflektion(totale Reflektion) oder bei kleinem Winkel fast komplett die Brechung dargestelt wird.

Ich hoffe diese kleine Einführung in Erstellung von spiegelnden Oberflächen, inbesondere Wasser, hat ihnen einen Einblick in die Möglichkeiten von Webgl gegeben. Die Präsentationsfolien und Sourcecode stehen oben zum Download bereit.

Demonstration

Tasterturbelegung

m – Enable/Disable Framebuffer monitors

WASD – Move the Camera
Click+Move Mouse – Rotate Camera

 

Texturen & Beleuchtung

Demonstration

Downloads
[zip] 19.7 KBTesselation
[zip] 550 KBSimple Lighting
[zip] 12 MBEnvironment Mapping
[zip] 3.3 MBDynamic Reflection/Refraction
[zip] 13 MBTexturen & Beleuchtung
Quellen
WebsiteStephan Brumme Computer Grafik
PDFTessellation of a Unit Sphere
Website OpenGL SuperBible, Fifth Edition
WebsiteWhitaker’s Wiki – Creating a Toon Shader
WebsiteClickToRelease – Vertex displacement with a noise function
Website Real Time Rendering, 2. Auflage
WebsiteVoxelent – WebGL Beginner’s Guide
WebsiteWebGL Water by Evan Eallace
WebsiteReflections and Refractions for Finding Nemo
PDFRefraction of Water Surface Intersecting
GithubWebGL Terrain, Ocean, Fog by Jonas Wagner

Kommentare sind geschlossen.