Einführung in Shader

Paul Lewis

Einführung

Ich habe Ihnen bereits eine Einführung in Three.js gegeben. Falls Sie das noch nicht getan haben, sollten Sie es nachholen, da ich in diesem Artikel darauf aufbauen werde.

Ich möchte über Shader sprechen. WebGL ist brillant und wie ich bereits gesagt habe, ist Three.js (und andere Bibliotheken) ein hervorragendes Mittel, um Schwierigkeiten zu entschärfen. Es kann aber vorkommen, dass Sie einen bestimmten Effekt erzielen oder etwas genauer wissen möchten, wie diese erstaunlichen Dinge auf Ihrem Bildschirm erscheinen. In diesem Fall spielen Shader fast immer eine Rolle. Wenn Sie so sind wie ich, möchten Sie vielleicht nach den Grundlagen im letzten Tutorial etwas Schwierigeres ausprobieren. Ich gehe davon aus, dass Sie Three.js verwenden, da es uns viel Arbeit abnimmt, den Shader zum Laufen zu bringen. Ich erwähne auch von Anfang an, dass ich zu Beginn den Kontext für Shader erklären werde und dass wir im letzten Teil dieses Tutorials in etwas komplexere Themen einsteigen. Der Grund dafür ist, dass Shader auf den ersten Blick ungewöhnlich sind und etwas erklären müssen.

1. Unsere beiden Shader

WebGL bietet keine Verwendung der festen Pipeline, mit der nur kurz gesagt werden kann, dass Sie damit Ihre Daten nicht sofort bereitstellen können. Was es jedoch anbietet, ist die programmierbare Pipeline, die leistungsstärker, aber auch schwieriger zu verstehen und zu verwenden ist. Kurz gesagt bedeutet die programmierbare Pipeline, dass Sie als Programmierer dafür verantwortlich sind, dass die Vertices usw. auf dem Bildschirm gerendert werden. Shader sind ein Teil dieser Pipeline. Es gibt zwei Arten von ihnen:

  1. Vertex-Shader
  2. Fragment-Shader

Beides bedeutet für sich genommen absolut nichts, wie Sie sicher zustimmen werden. Sie sollten wissen, dass beide vollständig auf der GPU Ihrer Grafikkarte ausgeführt werden. Das bedeutet, dass wir so viel wie möglich an sie auslagern möchten, damit unsere CPU andere Aufgaben erledigen kann. Eine moderne GPU ist stark für die Funktionen optimiert, die Shader benötigen. Daher ist es hervorragend geeignet, sie verwenden zu können.

2. Vertex Shader

Nehmen Sie eine primitive Standardform wie eine Kugel. Sie besteht aus Eckpunkten, richtig? Ein Vertex-Shader erhält jeden dieser Eckpunkte nacheinander und kann damit herumspielen. Wie der Vertex-Shader mit den einzelnen Scheitelpunkten arbeitet, liegt in seiner Verantwortung: Irgendwann muss er einen sogenannten gl_Position festlegen, einen 4D-Gleitkommavektor. Dies ist die endgültige Position des Scheitelpunkts auf dem Bildschirm. Das ist an sich ein ziemlich interessanter Prozess, da es darum geht, eine 3D-Position (einen Punkt mit x, y, z) auf einen 2D-Bildschirm zu projizieren. Zum Glück haben wir mit Three.js eine kurze Möglichkeit, die gl_Position festzulegen, ohne dass dies zu sehr wird.

3. Fragment-Shader

Wir haben ein Objekt mit Scheitelpunkten und diese auf den 2D-Bildschirm projiziert. Aber was ist mit den Farben, die wir verwenden? Was ist mit Textur und Beleuchtung? Genau dafür ist der Fragment-Shader da. Ähnlich wie beim Vertex-Shader hat auch der Fragment-Shader nur eine Aufgabe: Er muss die Variable gl_FragColor, einen weiteren 4D-Floatvektor, der die endgültige Farbe unseres Fragments enthält, festlegen oder verwerfen. Aber was ist ein Fragment? Denken Sie an drei Eckpunkte, die ein Dreieck bilden. Jedes Pixel in diesem Dreieck muss herausgezeichnet werden. Bei einem Fragment handelt es sich um die Daten, die von diesen drei Scheitelpunkten zum Zeichnen der einzelnen Pixel in diesem Dreieck bereitgestellt werden. Daher erhalten die Fragmente interpolierte Werte von ihren zugehörigen Eckpunkten. Wenn ein Scheitelpunkt rot und sein Nachbar blau ist, interpolieren sich die Farbwerte von Rot über Lila zu Blau.

4. Shader-Variablen

Es gibt drei Arten von Variablen: Uniforms, Attributes und Varyings. Als ich zum ersten Mal von diesen drei Dingen hörte, war ich sehr verwirrt, da sie nicht mit anderen Dingen übereinstimmten, mit denen ich bisher gearbeitet hatte. So können Sie sich das vorstellen:

  1. Uniforms werden sowohl an Vertex- als auch an Fragment-Shader gesendet und enthalten Werte, die im gesamten gerenderten Frame gleich bleiben. Ein gutes Beispiel hierfür ist die Position einer Lampe.

  2. Attribute sind Werte, die auf einzelne Eckpunkte angewendet werden. Attribute sind nur für den Vertex-Shader verfügbar. So könnte jeder Knoten eine eigene Farbe haben. Zwischen Attributen und Scheitelpunkten besteht eine Eins-zu-Eins-Beziehung.

  3. Variierende Variablen sind Variablen, die im Vertex-Shader deklariert werden und die wir für den Fragment-Shader freigeben möchten. Dazu deklarieren wir sowohl im Vertex- als auch im Fragment-Shader eine Variable mit demselben Typ und Namen. Ein klassisches Beispiel hierfür ist die Normale eines Vertex, da sie in den Beleuchtungsberechnungen verwendet werden kann.

Später verwenden wir alle drei Typen, damit Sie ein Gefühl dafür bekommen, wie sie in der Praxis angewendet werden.

Nachdem wir über Vertex- und Fragment-Shader und die Arten von Variablen gesprochen haben, mit denen sie arbeiten, sehen wir uns nun die einfachsten Shader an, die wir erstellen können.

5. Bonjourno-Welt

Hier sehen Sie die „Hello World“-Funktion der Vertex-Shader:

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

Und hier ist dasselbe für den Fragment-Shader:

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

Nicht zu kompliziert, oder?

Im Vertex-Shader werden von Three.js einige Uniformen gesendet. Diese beiden Uniformen sind 4D-Matrizen, die Model-View-Matrix und die Projektionsmatrix genannt werden. Sie müssen nicht unbedingt genau wissen, wie sie funktionieren, aber es ist immer am besten, wenn Sie verstehen, wie etwas funktioniert. Kurz gesagt: Sie geben an, wie die 3D-Position des Scheitelpunkts auf die endgültige 2D-Position auf dem Bildschirm projiziert wird.

Ich habe sie aus dem Snippet oben herausgelassen, da sie von Three.js oben in den Shadercode eingefügt werden. Tatsächlich werden aber noch viel mehr Daten hinzugefügt, z. B. Lichtdaten, Vertex-Farben und Vertex-Normalen. Wenn Sie dies ohne Three.js tun würden, müssten Sie alle diese Uniformen und Attribute selbst erstellen und festlegen. Wahre Geschichte.

6. MeshShaderMaterial verwenden

Okay, wir haben einen Shader eingerichtet, aber wie verwenden wir ihn mit Three.js? Es ist ganz einfach. Es ist eher so:

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

Anschließend kompiliert und führt Three.js die Shader aus, die mit dem Mesh verbunden sind, dem Sie dieses Material zuweisen. Einfacher geht es nicht. Das ist wahrscheinlich richtig, aber wir sprechen hier von 3D-Inhalten, die in Ihrem Browser ausgeführt werden. Daher gehen Sie wahrscheinlich von einer gewissen Komplexität aus.

Wir können unserem MeshShaderMaterial noch zwei weitere Eigenschaften hinzufügen: Shaderuniformen und Shaderattribute. Sie können sowohl Vektoren, Ganzzahlen als auch Gleitkommazahlen annehmen. Wie bereits erwähnt, sind Uniforms jedoch für den gesamten Frame, d. h. für alle Vertexe, gleich. Daher sind sie in der Regel einzelne Werte. Attribute sind jedoch Variablen pro Knoten und sollten daher ein Array sein. Es sollte eine Eins-zu-Eins-Beziehung zwischen der Anzahl der Werte im Attribut-Array und der Anzahl der Eckpunkte im Mesh geben.

7. Nächste Schritte

Jetzt fügen wir einen Animations-Loop, Vertex-Attribute und eine Uniform hinzu. Wir fügen auch eine Variable ein, damit der Vertex-Shader einige Daten an den Fragment-Shader senden kann. Das Endergebnis ist, dass unsere Kugel, die vorher rosa war, von oben und seitlich beleuchtet erscheint und pulsiert. Das ist etwas seltsam, aber ich hoffe, dass Sie dadurch ein gutes Verständnis der drei Variablentypen sowie deren Beziehung zueinander und zur zugrunde liegenden Geometrie erhalten.

8. Ein Fake-Licht

Ändern wir die Färbung, damit es kein einfarbiges Objekt mehr ist. Wir könnten uns ansehen, wie Three.js mit Beleuchtung umgeht, aber wie Sie sich sicher vorstellen können, ist das komplexer, als wir es momentan brauchen. Deshalb faken wir es. Sehen Sie sich unbedingt die fantastischen Shader an, die zu Three.js gehören, und auch die Shader aus dem kürzlich erschienenen WebGL-Projekt Rome von Chris Milk und Google. Zurück zu unseren Shadern. Wir aktualisieren unseren Vertex-Shader, um jedem Vertex einen Normalvektor für den Fragment-Shader zur Verfügung zu stellen. Dazu setzen wir verschiedene Maßnahmen ein:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

Im Fragment-Shader verwenden wir denselben Variablennamen und dann die Punktprodukt der Normalen des Vertex mit einem Vektor, der ein Licht darstellt, das von oben und rechts auf die Kugel scheint. Das Ergebnis ist ein Effekt, der einem gerichteten Licht in einem 3D-Paket ähnelt.

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Das Skalarprodukt funktioniert also, weil es bei zwei Vektoren eine Zahl ergibt, die angibt, wie „ähnlich“ die beiden Vektoren sind. Wenn normalisierte Vektoren genau in dieselbe Richtung zeigen, erhalten Sie den Wert 1. Wenn sie in entgegengesetzte Richtungen zeigen, erhalten Sie -1. Wir nehmen diese Zahl und wenden sie auf unsere Beleuchtung an. Ein Vertex oben rechts hat also einen Wert nahe oder gleich 1, also vollständig beleuchtet, während ein Vertex an der Seite einen Wert nahe 0 und auf der Rückseite -1 hat. Wir setzen den Wert für negative Werte auf 0, aber wenn Sie die Zahlen eingeben, erhalten Sie die Grundbeleuchtung, die wir sehen.

Nächste Schritte Es wäre schön, wenn Sie ein paar Vertex-Positionen ändern könnten.

9. Attribute

Ich möchte, dass wir jedem Eckpunkt über ein Attribut eine Zufallszahl anhängen. Mit dieser Zahl schieben wir den Punkt entlang seiner Normale heraus. Das Endergebnis ist eine Art seltsamer Spikeball, der sich jedes Mal ändert, wenn Sie die Seite aktualisieren. Die Animation ist noch nicht zu sehen (das kommt als Nächstes), aber nach ein paar Seitenaktualisierungen sehen Sie, dass die Reihenfolge zufällig ist.

Beginnen wir mit dem Hinzufügen des Attributs zum Vertex-Shader:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Wie sieht es aus?

Nicht viel anders! Das liegt daran, dass das Attribut im MeshShaderMaterial nicht eingerichtet wurde. Daher verwendet der Shader stattdessen einen Nullwert. Es ist momentan so etwas wie ein Platzhalter. In einer Sekunde fügen wir das Attribut dem MeshShaderMaterial im JavaScript hinzu und Three.js verbindet die beiden automatisch für uns.

Außerdem musste ich die aktualisierte Position einer neuen vec3-Variablen zuweisen, da das ursprüngliche Attribut wie alle Attribute nur lesbar ist.

10. MeshShaderMaterial aktualisieren

Aktualisieren wir gleich unser MeshShaderMaterial mit dem Attribut, das für die Displacement-Map benötigt wird. Zur Erinnerung: Attribute sind Werte pro Vertikale. Wir benötigen also einen Wert pro Vertikale in unserer Kugel. Ein Beispiel:

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

Jetzt sehen wir eine verdrehte Kugel. Das Tolle daran ist, dass die gesamte Verschiebung auf der GPU erfolgt.

11. Sucker animieren

Das Ganze sollte animiert werden. Wie gehen wir vor? Es gibt zwei Dinge, die wir erledigen müssen:

  1. Eine Einheit, die animiert, wie viel Verschiebung in jedem Frame angewendet werden soll. Dazu können wir Sinus oder Kosinus verwenden, da sie von −1 bis 1 reichen.
  2. Eine Animationsschleife im JS

Wir fügen die Uniform sowohl dem MeshShaderMaterial als auch dem Vertex-Shader hinzu. Zuerst der Vertex-Shader:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Als Nächstes aktualisieren wir das MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

Unsere Shader sind jetzt fertig. Aber im Moment scheinen wir einen Schritt zurück gemacht zu haben. Das liegt vor allem daran, dass unser Amplitudenwert bei 0 liegt. Da wir diesen Wert mit der Verschiebung multiplizieren, ändert sich nichts. Außerdem haben wir die Animationsschleife nicht eingerichtet, sodass die „0“ nie in etwas anderes umgewandelt wird.

In unserem JavaScript-Code fassen wir nun den Rendering-Aufruf in einer Funktion zusammen. Verwenden Sie requestAnimationFrame, um sie aufzurufen. Dort müssen wir auch den Wert der Uniform aktualisieren.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. Fazit

Webseite. Jetzt siehst du, wie die Animation in einer seltsamen (und etwas seltsamen) pulsierenden Weise pulsiert.

Shader sind ein Thema, zu dem wir noch viel mehr erzählen könnten. Ich hoffe aber, dass Ihnen diese Einführung geholfen hat. Sie sollten jetzt in der Lage sein, Shader zu verstehen, wenn Sie sie sehen, und das Selbstvertrauen haben, eigene tolle Shader zu erstellen.