Shader - Nachbrenner für 3D-Projekte

Für die 3D-Darstellungen nutzen wir ja threejs. Damit kann man viel machen. Bei besonderen Effekten geht es aber ans Eingemachte: Dann programmieren wir direkt die GPU mit Shadern.

Was sind Shader?

Shader sind kleine Programme, geschrieben in der Sprache GLSL, die auf der GPU ausgeführt werden. Sie haben die Aufgabe, eher abstrakte 3D-Modelle schrittweise umzurechnen, bis sie auf dem Bildschirm dargestellt werden.

Das Besondere an der Shader-Entwicklung ist, dass die GPU hochgradig parallelisiert ist. Die Shader werden also massiv parallel ausgeführt. Darauf muss man natürlich achten.

Was für Shader gibt es?

GPU-Pipeline

Die Graphik zeigt, in welchen Schritten innerhalb der GPU aus einem 3D-Modell eine Bildschirmdarstellung wird:

  1. Zunächst liegt ein 3D-Modell in Form von abstrakten Daten vor. Meistens sind das Vertices (also 3D-Vektoren) mit daranhängenden Daten wie z.B. Farben oder Normalen-Vektoren.
  2. Der erste Shader, der Vertex-Shader, kann diese Daten manipulieren, also die Vertices selbst oder die daranhängenden Daten vorbearbeiten oder ändern. Das Ergebnis des Shaders ist für jeden Vertex seine Position 2D-Raum, auf den die Kamera schaut.
  3. Dann erfolgt die Rasterisierung: Sie interpoliert alle Attribute zwischen jeweils drei Vertices linear. Für jeden Bildschirmpunkt gibt sie dieinterpolierten Daten auf.
  4. Damit wird der zweite Shader, der Fragment-Shader aufgerufen. Er berechnet aus den vorliegenden Daten die Farbe des Punktes.
  5. Schliesslich kommt noch ein wenig Postprocessing, dann wird das Pixel auf dem Bildschirm gezeichnet.

Beispiel für einen Vertex-Shader

varying vec3 vNormal;
void main() {
  vNormal = normal;
  gl_Position = projectionMatrix *modelViewMatrix *vec4(position, 1.0);
}

Das ist der langweiligste Vertex-Shader, den man sich vorstellen kann. Er berechnet zwei Ergebnisse: vNormal ist der Normalen-Vektor an dem Punkt. Er wird unverändert übernommen. Die Tatsache, dass die Variable als varying definiert ist, bedeutet, dass sie bei der Rasterisierung interpoliert wird. Variablen die nur einen Wert haben, sind uniform. gl_Position ist die finale Position des Punktes im Projektionsraum. threejs gibt netterweise alle Matrizen direkt mit, die notwendig sind, um den 3D-Punkt auf den 2D-Punkt zu projizieren.

Was man sieht, ist, dass Vektor- und Matrixoperationen in GLSL wirklich kein Problem darstellen.

Beispiel für einen Fragment-Shader

varying vec3 vNormal;

void main() {
  vec3light = vec3(0.5, 0.2, 1.0);
  light = normalize(light);
  floatdotProd = max(0.0, dot(vNormal, light));
  gl_FragColor = vec4(dotProd,  dotProd, dotProd, 1.0);
}

In diesem Shader wird angenommen dass aus (0.5, 0.2, 1.0) ein DirectionalLight kommt. Das Skalarprodukt mit dem Normalenvektor ergibt im einfachsten Fall die Helligkeit der Oberfläche an dieser Stelle (einfach mal mit den beiden Händen nachstellen…).

Der Shader gibt das in Graustufen ohne Transparenz aus und man erhält:

Fragment Shader Ergebnis

Shader in threejs

Wie kann man nun in threejs mit Shadern konkret umgehen?

Ganz einfach: Man tut es bereits ;-). Wenn man einem Mesh ein Material zuweist, passiert nichts anderes, als dass man damit einen Shader auswählt. Beim PhongMaterial den PhongShader, beim StandardMaterial eben den PBR Shader.

Man kann es aber auch auf die harte Tour machen, indem man ein Material wählt, dem man Shader explizit mitgeben muss:

const vertextShader = "...";
const fragmentShader = "...";

const material = new THREE.RawShaderMaterial({
  uniforms: {
    time: { 
      value: 1.0 
    },
    resolution: { 
      value: new THREE.Vector2() 
    }
  },
  vertexShader: vertextShader,
  fragmentShader: fragmentShader
});
var displacements = new Float32Array( MAX_POINTS ); 
for (i=0; i<MAX_POINTS; i++) displacements[i] = Math.random();
geometry.setAttribute( 'displacement', new THREE.BufferAttribute( displacements, 1 ) );

Hier werden die beiden Shader ganz einfach als String-Konstanten in JavaScript deklariert und dann dem RawShaderMaterial mitgegeben.

Gezeigt ist auch noch, wie man uniforms übergibt (hier time und resolution) und Attribute an Vertices setzt (hier displacement).

Man wird aber selten einen Shader komplett selbst schreiben, weil die eingebauten ja schon ziemlich gut sind. Wenn man einen vorhandenen Shader manipulieren will kann man so etwas tun wie:

material.onBeforeCompile = shader => {
  const my_uv_vertex = `#ifdef USE_UV
    ...eigener Code...
  #endif`;

  shader.vertexShader = 
    "uniform  float uOffset;\nuniform  float flip;\n" + 
    shader.vertexShader.replace("#include <uv_vertex>", my_uv_vertex);
}

Um zu sehen, was da geht, kann man sich einfach mal in der Chrome-Console THREE.ShaderLib ansehen, da hat man Zugriff auf alle Quelltexte…

Have fun!

Ach ja: Schöne Beispiele für Shader gibts auf shadertoy.

Weitere Infos