Einführung in Shader

Paul Lewis

Einleitung

Ich habe Ihnen bereits Three.js vorgestellt. Wenn Sie noch nicht gelesen haben, worum es geht, ist dies das Fundament, auf dem ich in diesem Artikel aufbauen werde.

Ich möchte über Shader sprechen. WebGL ist genial. Wie bereits erwähnt, lassen sich mit Three.js und anderen Bibliotheken die Schwierigkeiten wegzaubern. Manchmal möchten Sie jedoch einen bestimmten Effekt erzielen oder genauer untersuchen, wie diese erstaunlichen Inhalte auf Ihrem Bildschirm dargestellt wurden. Shader sind höchstwahrscheinlich Teil dieser Gleichung. Vielleicht möchten Sie auch wie ich von den grundlegenden Inhalten der letzten Anleitung zu etwas Schwierigerem übergehen. Ich arbeite auf der Grundlage, dass Sie Three.js verwenden, da es einen Großteil der Eselarbeit für uns übernimmt, um den Shader in Betrieb zu nehmen. Zu Beginn erläutere ich den Kontext für Shader und erläutere im letzten Teil dieser Anleitung, dass wir in etwas komplexere Themen einsteigen werden. Der Grund dafür ist, dass Shader auf den ersten Blick ungewöhnlich sind und etwas erklärend sein müssen.

1. Unsere zwei Shader

WebGL bietet keine Nutzung einer festen Pipeline. Damit können wir ganz kurz sagen, dass es Ihnen keine Möglichkeit bietet, Ihre Inhalte standardmäßig zu rendern. Sie bietet jedoch die Programmable Pipeline, die leistungsfähiger, aber auch schwieriger zu verstehen und zu verwenden ist. Kurz gesagt bedeutet „Programmable Pipeline“ als Programmierer, dass Sie für das Rendern der Eckpunkte usw. auf dem Bildschirm verantwortlich sind. Shader sind ein Teil dieser Pipeline. Es gibt zwei Arten von ihnen:

  1. Vertex-Shader
  2. Fragment-Shader

Beides bedeutet, ich bin sicher, dass Sie mir zustimmen, überhaupt nichts. Sie sollten jedoch wissen, dass sie beide vollständig mit der GPU Ihrer Grafikkarte ausgeführt werden. Das bedeutet, dass wir alles entlasten wollen, was wir können, und unsere CPU für andere Aufgaben überlassen wollen. Eine moderne GPU ist stark für die Funktionen optimiert, die Shader benötigen, daher ist es großartig, sie verwenden zu können.

2. Vertex-Shader

Nehmen Sie eine einfache Standardform, z. B. eine Kugel. Sie besteht aus Eckpunkten, richtig? Ein Vertex-Shader erhält nacheinander jeden einzelnen dieser Eckpunkte und kann mit ihnen experimentieren. Der Vertex-Shader entscheidet, was er mit jedem einzelnen tut, hat aber eine Aufgabe: Er muss irgendwann die gl_Position festlegen, einen 4D-Gleitkommazahl-Vektor, also die endgültige Position des Scheitelpunkts auf dem Bildschirm. An sich ist das ein ziemlich interessanter Prozess, da es hier um das Abrufen einer 3D-Position (ein Scheitelpunkt mit x, y,z) auf einem 2D-Bildschirm geht. Glücklicherweise können wir gl_Position bei Verwendung von Three.js auf kurze Weise festlegen, ohne dass es zu schwer wird.

3. Fragment-Shader

Das Objekt mit den Eckpunkten wird auf den 2D-Bildschirm projiziert, aber was ist mit den Farben? Und 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 Aufgabe: Er muss die Variable gl_FragColor festlegen oder verwerfen, einen weiteren 4D-Float-Vektor, die endgültige Farbe des Fragments. Aber was ist ein Fragment? Stellen Sie sich drei Eckpunkte vor, die ein Dreieck bilden. Jedes Pixel in diesem Dreieck muss gezeichnet werden. Ein Fragment sind die Daten, die von diesen drei Eckpunkten bereitgestellt werden, um jedes Pixel in diesem Dreieck zu zeichnen. Aus diesem Grund erhalten die Fragmente interpolierte Werte von ihren einzelnen Eckpunkten. Wenn ein Scheitelpunkt rot und der Nachbar blau ist, sehen wir, dass die Farbwerte von Rot über Lila zu Blau interpoliert werden.

4. Shader-Variablen

In Bezug auf Variablen können Sie drei Deklarationen angeben: Uniformen, Attribute und Varyings. Als ich zum ersten Mal von diesen drei Funktionen hörte, war ich sehr verwirrt, da sie mit keinem anderen übereinstimmen. Diese stellen wir Ihnen folgendermaßen vor:

  1. Uniformen werden sowohl an Vertex-Shader als auch an Fragment-Shader gesendet und enthalten Werte, die über den gesamten gerenderten Frame hinweg gleich bleiben. Ein gutes Beispiel dafür ist die Position einer Leuchte.

  2. Attribute sind Werte, die auf einzelne Eckpunkte angewendet werden. Attribute sind nur für den Vertex-Shader verfügbar. Das kann z. B. sein, dass jeder Scheitelpunkt eine andere Farbe hat. Attribute haben eine Eins-zu-eins-Beziehung zu Eckpunkten.

  3. Varyings sind im Vertex-Shader deklarierte Variablen, die für den Fragment-Shader freigegeben werden sollen. Dazu deklarieren wir eine veränderliche Variable desselben Typs und Namens sowohl im Vertex-Shader als auch im Fragment-Shader. Eine klassische Verwendung wäre die normale Verwendung eines Scheitelpunkts, da diese bei den Beleuchtungsberechnungen verwendet werden kann.

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

Nachdem wir über Vertex-Shader und Fragment-Shader und die Variablentypen gesprochen haben, mit denen sie zu tun haben, lohnt es sich, einen Blick auf die einfachsten Shader zu werfen, die wir erstellen können.

5. Bonjourno-Welt

Hier ist also die Hello World of 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);
}

Es ist aber gar nicht so kompliziert, oder?

Im Vertex-Shader erhalten wir einige Uniformen von Three.js. Diese beiden Uniformen sind 4D-Matrizen, die Model-View-Matrix und die Projektionsmatrix genannt werden. Sie müssen nicht unbedingt genau wissen, wie diese funktionieren, aber es ist immer am besten zu verstehen, wie die Dinge funktionieren. Kurz gesagt: Sie zeigen, wie die 3D-Position des Scheitelpunkts tatsächlich zur 2D-Endposition auf dem Bildschirm projiziert wird.

Ich habe sie im Snippet oben weggelassen, weil Three.js sie ganz oben in den Shader-Code einfügt, sodass Sie sich darüber keine Gedanken machen müssen. Ehrlich gesagt fügt sie sogar noch viel mehr hinzu, wie Lichtdaten, Scheitelpunktfarben und Normalen von Scheitelpunkten. Ohne Three.js müssten Sie alle Uniformen und Attribute selbst erstellen und festlegen. Wahre Geschichte.

6. Verwendung von MeshShaderMaterial

Wir haben also einen Shader eingerichtet, aber wie verwenden wir ihn mit Three.js? Es stellt sich heraus, dass es unglaublich einfach ist. Sie sieht ungefähr so aus:

/**
* 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()
});

Von dort kompiliert Three.js die Shader, die an das Mesh-Netzwerk angehängt sind, dem Sie dieses Material übergeben, und führt sie aus. Einfacher geht es nicht. Wahrscheinlich stimmt das, aber wir sprechen über 3D in Ihrem Browser. Daher nehme ich an, dass Sie eine gewisse Komplexität erwarten.

Wir können unserem MeshShaderMaterial sogar noch zwei weitere Eigenschaften hinzufügen: Uniformen und Attribute. Beide können Vektoren, Ganzzahlen oder Gleitkommazahlen annehmen, aber wie bereits erwähnt, sind Uniformen für den gesamten Frame gleich, d.h. für alle Eckpunkte, sodass sie tendenziell Einzelwerte sind. Attribute sind Variablen pro Scheitelpunkt. Sie müssen also ein Array sein. Zwischen der Anzahl der Werte im Attributarray und der Anzahl der Eckpunkte im Mesh-Netzwerk sollte eine 1:1-Beziehung vorliegen.

7. Nächste Schritte

Jetzt werden wir eine Animationsschleife, Vertex-Attribute und eine Uniform hinzufügen. Außerdem fügen wir eine variable Variable hinzu, damit der Vertex-Shader einige Daten an den Fragment-Shader senden kann. Das Endergebnis ist, dass unsere rosafarbene Kugel von oben und zur Seite leuchten scheint und pulsiert. Es ist etwas seltsam, aber ich hoffe, dass Sie dadurch die drei Variablentypen sowie deren Beziehung zueinander und die zugrunde liegende Geometrie besser verstehen.

8. Eine falsche Lampe

Wir aktualisieren die Farbe, damit es sich nicht mehr um ein flaches Farbobjekt handelt. Wir könnten uns ansehen, wie Three.js mit Beleuchtung umgeht, aber ihr wisst bestimmt, dass es komplexer ist, als wir gerade benötigen, also fälschen wir es. Sie sollten sich die fantastischen Shader ansehen, die Teil von Three.js sind, sowie diejenigen, die aus dem letzten fantastischen WebGL-Projekt von Chris Milk und Google, Rome stammen. Zurück zu unseren Shadern. Wir aktualisieren unseren Vertex Shader, um dem Fragment Shader jeden normalen Scheitelpunkt zur Verfügung zu stellen. Dazu wird ein unterschiedlicher Wert verwendet:

// 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 richten wir den gleichen Variablennamen ein und verwenden dann das Skalarprodukt der Scheitelpunktnormalen mit einem Vektor, der ein von oben und rechts von der Kugel gerichtetes Licht darstellt. Das Endergebnis ergibt einen Effekt, der dem Richtungslicht 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 so, dass es bei zwei Vektoren eine Zahl gibt, die angibt, wie „ähnlich“ die beiden Vektoren sind. Wenn bei normalisierten Vektoren sie in genau die gleiche Richtung zeigen, erhalten Sie den Wert 1. Zeigen sie in entgegengesetzte Richtungen, erhalten Sie -1. Wir nehmen diese Zahl und wenden sie auf unsere Beleuchtung an. Ein Scheitelpunkt oben rechts hat also einen Wert nahe oder gleich 1, d.h., er wird vollständig beleuchtet, während ein Scheitelpunkt auf der Seite einen Wert nahe 0 hat und der Rückenteil -1. Wir setzen den Wert für alles Negative auf 0, aber wenn Sie die Zahlen eingeben, erhalten Sie die Grundbeleuchtung, die wir sehen.

Nächste Schritte Nun, es wäre schön, vielleicht versuchen, ein paar Scheitelpunkte zu experimentieren.

9. Attribute

Wir sollen nun jedem Scheitelpunkt über ein Attribut eine Zufallszahl zuordnen. Wir verwenden diese Zahl, um den Scheitelpunkt wie normal zu verschieben. Das Endergebnis ist ein seltsamer Spikeball, der sich bei jeder Aktualisierung der Seite ändert. Die Animation wird zwar noch nicht erst als Nächstes ausgeführt, aber ein paar Seitenaktualisierungen zeigen, dass sie zufällig angeordnet 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?

Eigentlich nicht viel anders! Das liegt daran, dass das Attribut nicht im MeshShaderMaterial eingerichtet wurde, sodass der Shader stattdessen einen Nullwert verwendet. Das ist im Moment wie ein Platzhalter. Jetzt fügen wir das Attribut im JavaScript zum MeshShaderMaterial hinzu, und Three.js verknüpft die beiden automatisch.

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

10. MeshShaderMaterial aktualisieren

Beginnen wir mit der Aktualisierung unseres MeshShaderMaterial mit dem Attribut, das für die Verschiebung benötigt wird. Zur Erinnerung: Attribute sind Werte pro Scheitelpunkt. Daher benötigen wir in unserer Kugel einen Wert pro Scheitelpunkt. 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, aber das Tolle ist, dass die gesamte Verschiebung auf der GPU erfolgt.

11. Diesen Sauger animieren

Das sollten wir unbedingt animieren. Wie machen wir das? Nun, es gibt zwei Dinge, die wir schaffen müssen:

  1. Eine Uniform zur Animation der Verschiebung, die in jedem Frame angewendet werden soll. Dafür können wir Sinus oder Kosinus verwenden, da sie zwischen -1 und 1 liegen.
  2. Eine Animationsschleife im JS

Wir fügen die Uniform sowohl zu MeshShaderMaterial als auch zum 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 vorerst fertig. Aber richtig, wir scheinen einen Schritt zurückgegangen zu sein. Das liegt vor allem daran, dass unser Amplitudenwert bei 0 liegt und da wir dies mit der Verschiebung multiplizieren, ändert sich nichts. Wir haben auch keine Animationsschleife eingerichtet, sodass wir nie sehen, dass „0“ zu etwas anderem wechselt.

In unserem JavaScript müssen wir den Rendering-Aufruf in eine Funktion zusammenfassen und sie dann mit requestAnimationFrame aufrufen. 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. Die Animation hat einen seltsamen (und leicht stolpern) Puls.

Es gibt noch so viel mehr, dass wir über Shader als Thema behandeln können, aber ich hoffe, Sie fanden diese Einführung hilfreich. Sie sollten jetzt in der Lage sein, Shader zu verstehen, wenn Sie sie sehen, und die Gewissheit haben, eigene erstaunliche Shader zu erstellen.