Einführung in Shader

Paul Lewis

Einführung

Lesen Sie zuerst die Einführung zu Three.js, da sie die Grundlage für dieses Dokument bildet.

Ich möchte über Shader sprechen. WebGL ist brillant und wie ich bereits gesagt habe, abstrahieren Three.js (und andere Bibliotheken) die Schwierigkeiten für Sie. Es wird jedoch Zeiten geben, in denen Sie einen bestimmten Effekt erzielen oder etwas genauer untersuchen möchten, wie diese erstaunlichen Dinge auf Ihrem Bildschirm angezeigt werden. Shader sind dabei fast immer ein wichtiger Faktor. Vielleicht möchten Sie von den Grundlagen im letzten Tutorial zu etwas Schwierigerem übergehen.

Ich gehe davon aus, dass Sie Three.js verwenden, da es uns viel Arbeit abnimmt, wenn es um die Verwendung von Shadern geht. Zuerst erfahren Sie mehr über den Kontext von Shadern. Erweiterte Schritte werden später in diesem Dokument behandelt.

1. Unsere beiden Shader

WebGL bietet keine feste Pipeline. Das bedeutet, dass Sie Ihre Inhalte nicht ohne Weiteres rendern können. Stattdessen bietet es die programmierbare Pipeline, die leistungsstärker, aber auch schwieriger zu verstehen und zu verwenden ist. Kurz gesagt: Bei der programmierbaren Pipeline sind Sie als Programmierer dafür verantwortlich, dass die Eckpunkte usw. auf dem Bildschirm gerendert werden. Shader sind Teil dieser Pipeline. Es gibt zwei Arten:

  1. Vertex-Shader
  2. Fragment-Shader

Beide bedeuten für sich genommen nichts. 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 gut, sie verwenden zu können.

2. Vertex-Shader

Nehmen wir eine Standardform wie eine Kugel. Sie besteht aus Eckpunkten. Ein Vertex-Shader erhält jeden dieser Eckpunkte einzeln und kann sie bearbeiten. Es liegt am Vertex-Shader was er mit jedem einzelnen macht. Er hat jedoch eine Aufgabe: Er muss irgendwann etwas namens gl_Position festlegen, einen 4D-Gleitkommavektor, der die endgültige Position des Eckpunkts auf dem Bildschirm angibt. Das ist an sich schon ein interessanter Prozess, denn es geht darum, eine 3D-Position (einen Eckpunkt mit x, y, z) auf einen 2D-Bildschirm zu übertragen oder zu projizieren. Wenn wir etwas wie Three.js verwenden, können wir die gl_Position zum Glück auf einfache Weise festlegen, ohne dass es zu kompliziert wird.

3. Fragment-Shader

Wir haben also unser Objekt mit seinen Eckpunkten und haben sie auf den 2D-Bildschirm projiziert. Aber was ist mit den Farben, die wir verwenden? Was ist mit Texturen und Beleuchtung? Genau dafür ist der Fragment-Shader da. Ähnlich wie der Vertex-Shader hat auch der Fragment-Shader nur eine Pflichtaufgabe: Er muss die Variable gl_FragColor festlegen oder verwerfen. Das ist ein weiterer 4D-Gleitkommavektor, der die endgültige Farbe unseres Fragments angibt.

Aber was ist ein Fragment? Stellen Sie sich drei Eckpunkte vor, die ein Dreieck bilden. Jeder Pixel innerhalb dieses Dreiecks muss gezeichnet werden. Ein Fragment sind die Daten, die von diesen drei Eckpunkten bereitgestellt werden, um jeden Pixel in diesem Dreieck zu zeichnen. Daher erhalten die Fragmente interpolierte Werte von ihren Eckpunkten. Wenn ein Eckpunkt rot und sein Nachbar blau ist, werden die Farbwerte von Rot über Lila zu Blau interpoliert.

4. Shader-Variablen

Es gibt drei Variablendeklarationen, die Sie vornehmen können: Uniforms, Attributes und Varyings. Diese können verwirrend sein, da sie mit keiner anderen gängigen Sprache übereinstimmen. So können Sie sie sich vorstellen:

  1. Uniforms werden an beide Shader (Vertex-Shader und Fragment-Shader) gesendet und enthalten Werte, die für das gesamte gerenderte Frame gleich bleiben. Ein gutes Beispiel dafür ist die Position einer Lichtquelle.

  2. Attributes sind Werte, die auf einzelne Eckpunkte angewendet werden. Attribute sind nur für den Vertex-Shader verfügbar. Ein Beispiel dafür ist, dass jeder Eckpunkt eine eigene Farbe hat. Attribute haben eine 1:1-Beziehung zu Eckpunkten.

  3. Varyings sind Variablen, die in dem Vertex-Shader deklariert werden und die wir mit dem Fragment-Shader teilen möchten. Dazu deklarieren wir sowohl im Vertex-Shader als auch im Fragment-Shader eine Varying-Variable mit demselben Typ und Namen. Ein klassisches Beispiel dafür ist die Normale eines Eckpunkts, 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 tatsächlich angewendet werden.

Sehen wir uns einen der einfachsten Shader an, die Sie erstellen können.

5. Bonjourno World

Hier ist das „Hallo Welt“ 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 dasselbe für den Fragment-Shader:

/**
* Set the color 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 uns von Three.js einige Uniforms gesendet. Diese beiden Uniforms sind 4D-Matrizen, die als Model-View-Matrix und Projektionsmatrix bezeichnet werden. Sie müssen nicht genau wissen, wie sie funktionieren. Es ist jedoch immer besser, zu verstehen, wie die Dinge funktionieren, wenn Sie die Möglichkeit dazu haben. Kurz gesagt: Sie geben an, wie die 3D-Position des Eckpunkts auf die endgültige 2D-Position auf dem Bildschirm projiziert wird.

Ich habe sie im Beispiel-Snippet weggelassen, da Three.js sie selbst oben in Ihrem Shader-Code hinzufügt. Sie müssen sich also nicht darum kümmern. Tatsächlich werden noch viel mehr Dinge hinzugefügt, z. B. Lichtdaten, Eckpunktfarben und Eckpunktnormalen. Wenn Sie das ohne Three.js tun würden, müssten Sie alle diese Uniforms und Attribute selbst erstellen und festlegen. Das ist die Wahrheit.

6. MeshShaderMaterial verwenden

Wir haben also einen Shader eingerichtet. Aber wie verwenden wir ihn mit Three.js?

/**
* 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 Ihre Shader aus, die mit dem Mesh verknüpft sind, dem Sie dieses Material zuweisen. Einfacher geht es kaum. Wahrscheinlich schon, aber wir sprechen hier von 3D-Grafiken, die in Ihrem Browser ausgeführt werden. Daher gehe ich davon aus, dass Sie eine gewisse Komplexität erwarten.

Wir können unserem MeshShaderMaterial zwei weitere Attribute hinzufügen: Uniforms und Attribute. Beide können Vektoren, Ganzzahlen oder Gleitkommazahlen annehmen. Wie bereits erwähnt, sind Uniforms jedoch für das gesamte Frame oder für alle Eckpunkte gleich. Daher sind sie in der Regel einzelne Werte. Attribute sind jedoch Variablen pro Eckpunkt. Daher wird erwartet, dass sie ein Array sind. Es sollte eine 1:1-Beziehung zwischen der Anzahl der Werte im Array der Attribute und der Anzahl der Eckpunkte im Mesh bestehen.

7. Nächste Schritte

Jetzt fügen wir eine Animationsschleife, Eckpunktattribute und eine Uniform hinzu. Außerdem fügen wir eine Varying-Variable hinzu, damit der Vertex-Shader einige Daten an den Fragment-Shader senden kann. Das Endergebnis ist, dass unsere rosa Kugel von oben und von der Seite beleuchtet wird und pulsiert. Das ist etwas verrückt, aber hoffentlich hilft es Ihnen, die drei Variablentypen sowie ihre Beziehung zueinander und zur zugrunde liegenden Geometrie zu verstehen.

8. Gefälschtes Licht

Aktualisieren Sie nun die Farbgebung, damit es sich nicht um ein einfarbiges Objekt handelt. Wir könnten uns ansehen, wie Three.js die Beleuchtung handhabt. Das ist jedoch komplexer als nötig. Daher werden wir sie fälschen. Sie sollten sich unbedingt die fantastischen Shader ansehen, die Teil von Three.js sind, und auch die Shader aus dem erstaunlichen WebGL-Projekt von Chris Milk und Google, Rome. Zurück zu unseren Shadern. Wir aktualisieren unseren Vertex-Shader, um dem Fragment-Shader die Normale jedes Eckpunkts zur Verfügung zu stellen. Dazu verwenden wir eine Varying-Variable:

// 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 legen wir denselben Variablennamen fest und verwenden dann das Skalarprodukt der Eckpunktnormale mit einem Vektor, der eine Lichtquelle darstellt, die von oben und rechts von der Kugel scheint. Das Ergebnis ist ein Effekt, der dem einer gerichteten Lichtquelle 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 color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Das Skalarprodukt funktioniert, weil es bei zwei Vektoren eine Zahl ergibt, die angibt, wie ähnlich die beiden Vektoren sind. Bei normalisierten Vektoren erhalten Sie den Wert 1, wenn sie genau in dieselbe Richtung zeigen. Wenn sie in entgegengesetzte Richtungen zeigen, erhalten Sie -1. Wir nehmen diese Zahl und wenden sie auf unsere Beleuchtung an. Ein Eckpunkt oben rechts hat also einen Wert nahe oder gleich 1 (vollständig beleuchtet), während ein Eckpunkt an der Seite einen Wert nahe 0 und hinten einen Wert von -1 hat. Wir begrenzen den Wert für alle negativen Werte auf 0. Wenn Sie die Zahlen eingeben, erhalten Sie die grundlegende Beleuchtung, die wir sehen.

Wie geht es weiter? Vielleicht sollten wir versuchen, einige Eckpunktpositionen zu bearbeiten.

9. Attribute

Jetzt möchten wir jedem Eckpunkt mithilfe eines Attributs eine Zufallszahl zuweisen. Wir verwenden diese Zahl, um den Eckpunkt entlang seiner Normale zu verschieben. Das Ergebnis ist eine Art seltsamer Stachelball, der sich jedes Mal ändert, wenn Sie die Seite aktualisieren. Er ist noch nicht animiert (das kommt als Nächstes), aber nach einigen Aktualisierungen der Seite sehen Sie, dass er zufällig ist.

Fügen Sie zuerst das Attribut zum Vertex-Shader hinzu:

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);
}

Wirkt sie optisch ansprechend?

Nicht viel anders! Das liegt daran, dass das Attribut nicht im MeshShaderMaterial eingerichtet wurde. Daher verwendet der Shader stattdessen einen Wert von 0. Es ist im Moment eine Art Platzhalter. Gleich fügen wir das Attribut im JavaScript zum MeshShaderMaterial hinzu. Three.js verknüpft die beiden dann automatisch.

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

10. MeshShaderMaterial aktualisieren

Aktualisieren wir unser MeshShaderMaterial mit dem Attribut, das für unsere Verschiebung erforderlich ist. Zur Erinnerung: Attribute sind Werte pro Eckpunkt. Wir benötigen also einen Wert pro Eckpunkt 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 verformte Kugel. Das Tolle daran ist, dass die gesamte Verschiebung auf der GPU erfolgt.

11. Animation hinzufügen

Wir sollten das unbedingt animieren. Wie sollen wir das machen? Dazu müssen wir zwei Dinge einrichten:

  1. Eine Uniform, um zu animieren, wie viel Verschiebung in jedem Frame angewendet werden soll. Wir können dafür 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. Im Moment sieht es jedoch so aus, als hätten wir einen Schritt zurück gemacht. Das liegt vor allem daran, dass unser Amplitudenwert 0 ist. Da wir ihn mit der Verschiebung multiplizieren, ändert sich nichts. Außerdem haben wir die Animationsschleife nicht eingerichtet, sodass sich die 0 nie in etwas anderes ändert.

In unserem JavaScript müssen wir den Renderaufruf jetzt in eine Funktion einfügen und dann requestAnimationFrame verwenden, um ihn 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);

Fazit

Webseite. Sie sehen jetzt, dass sie auf seltsame (und etwas verrückte) Weise pulsiert.

Es gibt noch so viel mehr über Shader zu lernen. Ich hoffe, diese Einführung war hilfreich. Sie sollten jetzt Shader verstehen können, wenn Sie sie sehen, und das Selbstvertrauen haben, eigene erstaunliche Shader zu erstellen.