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 großartig und wie ich bereits erwähnt habe, abstrahieren Three.js (und andere Bibliotheken) die Schwierigkeiten für Sie. 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 möchte auch gleich vorwegsagen, dass ich am Anfang den Kontext für Shader erläutern werde und dass wir im letzten Teil dieses Tutorials etwas fortgeschrittenere Themen behandeln werden. Das liegt daran, dass Shader auf den ersten Blick ungewöhnlich sind und etwas erklärt werden müssen.
1. Unsere beiden Shader
WebGL bietet keine Möglichkeit, die feste Pipeline zu verwenden. Das bedeutet, dass Sie Ihre Inhalte nicht direkt rendern können. Was es jedoch bietet, 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 Eckpunkte usw. auf dem Bildschirm gerendert werden. Shader sind Teil dieser Pipeline und es gibt zwei Arten:
- Vertex-Shader
- 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 erfordern. Daher ist es toll, sie verwenden zu können.
2. Vertex-Shader
Nehmen Sie eine einfache geometrische Form wie eine Kugel. Sie besteht aus Eckpunkten, richtig? Ein Vertex-Shader erhält jeden dieser Eckpunkte nacheinander und kann damit herumspielen. Was der Vertex-Shader mit den einzelnen Vertex-Positionen tatsächlich macht, liegt in seinem Ermessen. Er hat jedoch eine Aufgabe: Er muss irgendwann gl_Position festlegen, einen 4D-Float-Vektor, der die endgültige Position des Vertex auf dem Bildschirm ist. 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. Glücklicherweise haben wir bei der Verwendung von etwas wie Three.js eine Kurzschreibweise, um gl_Position festzulegen, ohne dass die Dinge zu kompliziert werden.
3. Fragment-Shader
Wir haben also unser Objekt mit seinen Eckpunkten und haben sie auf den 2D-Bildschirm projiziert. Aber wie sieht es mit den verwendeten Farben aus? 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 ist, 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. Ein Fragment sind die Daten, die von diesen drei Eckpunkten zum Zeichnen der einzelnen Pixel in diesem Dreieck bereitgestellt werden. Daher erhalten die Fragmente interpolierte Werte von ihren zugehörigen 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 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 irgendetwas übereinstimmten, mit dem ich zuvor gearbeitet hatte. So können Sie sich das vorstellen:
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.
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. Attribute haben eine 1:1-Beziehung zu Eckpunkten.
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 World
Hier ist also 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 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 uns von Three.js einige Uniforms gesendet. Diese beiden Uniformen sind 4D-Matrizen, die als Modell-Ansichtsmatrix und Projektionsmatrix bezeichnet 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. Echte Geschichte.
6. MeshShaderMaterial verwenden
Ok, wir haben einen Shader eingerichtet, aber wie verwenden wir ihn mit Three.js? Es stellte sich heraus, dass es ganz einfach ist. 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 wirklich nicht. Das ist wahrscheinlich richtig, aber wir sprechen hier von 3D-Inhalten, die in Ihrem Browser ausgeführt werden. Ich nehme an, Sie erwarten eine gewisse Komplexität.
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. Außerdem fügen wir eine Variable hinzu, 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. Es ist ein bisschen trippy, aber hoffentlich hilft es Ihnen, die drei Variablentypen und ihre Beziehung zueinander und zur zugrunde liegenden Geometrie besser zu verstehen.
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 im Moment 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 Normalenvektor 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 Endergebnis ist ein Effekt, der einem 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, 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. Negative Werte werden auf 0 begrenzt. Wenn Sie die Zahlen eingeben, erhalten Sie die Grundbeleuchtung, die wir sehen.
Nächste Schritte Es wäre gut, wenn Sie die Positionen einiger Eckpunkte ändern könnten.
9. Attribute
Fügen wir jetzt jedem Knoten über ein Attribut eine Zufallszahl hinzu. 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.
Fügen Sie zuerst das Attribut dem 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);
}
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
Wir sollten das animieren. Wie gehen wir vor? Es gibt zwei Dinge, die wir beachten müssen:
- Eine Uniform, um zu animieren, wie viel Verschiebung in jedem Frame angewendet werden soll. Dazu können wir Sinus oder Kosinus verwenden, da sie von −1 bis 1 reichen.
- 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 und wir ihn mit der Verschiebung multiplizieren. Außerdem haben wir die Animationsschleife nicht eingerichtet, sodass die „0“ nie in etwas anderes umgewandelt wird.
In unserem JavaScript müssen wir den Renderaufruf jetzt in eine Funktion einschließen und 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. Sie sehen jetzt, dass es auf seltsame (und etwas trippy) pulsierende Weise animiert wird.
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.