Anzeige
Smartes Cloud Hosting für anspruchsvolle Projekte.
↬ Loslegen und Spaces testen ↬ Jetzt testen!
David Catuhe 1. Juli 2015

Licht und Schatten: Shader gestalten mit HTML5 und WebGL

In letzter Zeit war viel von babylon.js die Rede und vor kurzem haben wir babylon.js v2.0 veröffentlicht, das eine 3D Sound-Positionierung (mit WebAudio) sowie volumetrische Lichtstreuungen ermöglicht.

Anzeige

shaders-teaser_DE

Wer die Bekanntgabe von Version 1.0 verpasst hat, kann sich die Keynote dazu hier noch mal ansehen und direkt zum Abschnitt 2:24-2:28 gehen.

Anzeige

In dieser Keynote zeigen Steven Guggenheimer und John Shewchuk in einer Demo, wie die Unterstützung für Oculus Rift in Babylon.js eingebaut wurde. Und eines der wichtigsten Elemente dieser Demo war wiederum unsere Arbeit an einem speziellen Shader, der Objektive simulieren kann, wie in diesem Bild zu sehen ist:

Zusammen mit Frank Olivier und Ben Constable sprach ich auf der Build selbst in einer Session über die Grafik im Internet Explorer und in Babylon.js.

Daraus ergibt sich für mich auch gleich die Frage, die bei Diskussionen um babylon.js immer wieder auftaucht: Was ist mit Shadern eigentlich genau gemeint? Ein guter Grund also, heute mal zu zeigen, wie Shader funktionieren.

Die Theorie

Bevor wir mit dem Ausprobieren anfangen, sollten wir erstmal versuchen zu verstehen, was dabei im System genau passiert.

Wenn wir über mittels Hardware beschleunigtes 3D reden, geht es um zwei CPUs: die zentrale CPU und die GPU. Die GPU ist letztlich eine Art extrem spezialisierte CPU.

Die GPU ist eine Zustandsmaschine, die man mit Hilfe der CPU einrichtet. So wird die CPU zum Beispiel die GPU so konfigurieren, dass diese Linien rendert statt Dreiecke. Oder sie wird den Grad der Transparenz festlegen usw.

Wenn alle Zustände definiert sind, wird die CPU bestimmen, was genau wiedergegeben wird (die Geometrie, welche aus einer Liste von Punkten besteht (genauer: Scheitel- oder Eckpunkte, die im sogenannten Vertex Buffer gespeichert werden) und einer Index-Liste (die Flächen (oder Dreiecke), die im sogenannten Index Buffer gespeichert sind)).

Im letzten Schritt wird die CPU dann festlegen, wie die Geometrie schließlich genau gerendert wird. Und für diese spezielle Aufgabe definiert die CPU bestimmte Shaders für die GPU. Shaders sind letztlich ein Stück Code, das die GPU für alle Scheitel- bzw. Eckpunkte und für jeden Pixel ausführt, den es zu rendern gilt.

Zuerst ein paar Begriffserklärungen: Einen Scheitel- oder Eckpunkt (Englisch: Vertex) kann man sich als “Punkt” in einer 3D Umgebung vorstellen im Unterschied zu diesem Punkt in einer 2D Umgebung.

Es gibt zwei Arten von Shadern: den Vertex Shader und den Pixel (oder Fragment) Shader.

Grafik-Pipeline

Bevor wir richtig ins Thema Shaders einsteigen, noch kurz ein paar Grundlagen. Um Pixel darzustellen, nutzt die GPU die von der CPU vorgegebene Geometrie und tut das Folgende:

  • Mithilfe des Index Buffer werden drei Eckpunkte erfasst, um ein Dreieck festzulegen: Der Index Buffer enthält eine Liste von Vertex Indizes. Das heißt, jeder Eintrag im Index Buffer ist die Nummer eines Eckpunkts im Vertex Buffer. Auf diese Weise werden Duplikate von Eckpunkten vermieden. Im folgenden Beispiel besteht der Index Buffer aus einer Liste mit 2 Flächen: [1 2 3 1 3 4]. Die erste Fläche enthält Eckpunkt 1, Eckpunkt 2 und Eckpunkt 3. Die zweite Fläche enthält Eckpunkt 1, Eckpunkt 3 und Eckpunkt 4. In dieser Geometrie gibt es also vier Eckpunkte:
  • Der Vertex Shader wird auf jeden Eckpunkt des Dreiecks angewendet. Wichtigste Aufgabe des Vertex Shaders ist die Erstellung eines Pixels für jeden Eckpunkt (die Projektion des 3D Eckpunkts auf einem 2D Bildschirm):
  • Die GPU benutzt diese drei Pixel (die ein zweidimensionales Dreieck auf dem Bildschirm definieren), um alle diesem Pixel zugeordneten Werte zu interpolieren (zumindest die Position). Der Pixel Shader wird dann auf jeden Pixel der im 2D-Dreieck enthalten ist angewendet, um für jeden Pixel eine Farbe zu erstellen:
  • Dieser Prozess wird für jede Fläche durchgeführt, die der Index Buffer vorgibt.

Aufgrund seiner parallelen Eigenschaften kann die GPU diesen Schritt für viele Flächen gleichzeitig durchführen und dadurch entsprechend gute Ergebnisse hervorbringen.

GLSL

Wie wir gerade gesehen haben, benötigt die GPU zwei Shader, um Dreiecke darstellen zu können: den Vertex Shader und den Pixel Shader. Diese Shader werden mit GLSL (Graphics Library Shader Language) geschrieben. Sie sieht so aus wie C.

Für den Internet Explorer 11 haben wir ein Compiler-Programm entwickelt, um GLSL in HLSL (High Level Shader Language) umzuwandeln, was die Programmiersprache für Shader in DirectX 11 ist. Auf diese Weise gewährleistet der IE11 die Sicherheit des Shader Codes (keiner will schließlich seinen Rechner neu starten, um WebGL zu nutzen):

Hier ist ein Beispiel für einen häufig verwendeten Vertex Shader:

precision highp float;

// Attributes
attribute vec3 position;
attribute vec2 uv;

// Uniforms
uniform mat4 worldViewProjection;

// Varying
varying vec2 vUV;

void main(void) {
    gl_Position = worldViewProjection * vec4(position, 1.0);

    vUV = uv;
}

Struktur eines Vertex Shader

Ein Vertex Shader enthält Folgendes:

  • Attributes: Ein Attribut definiert einen Teil eines Eckpunkts. Standardmäßig sollte ein Eckpunkt zumindest die Position beinhalten (a vector3:x, y, z). Aber als Entwickler kann man auch weitere Daten hinzufügen. So gibt es im vorherigen Beispiel den vector2 namens uv (Koordinaten für eine Textur, mit der eine 2D-Textur auf ein 3D-Objekt angewendet werden kann.)
  • Uniforms: Das sind Variablen, die vom Shader benutzt und von der CPU festgelegt werden. Im Beispiel gibt es davon nur eine und zwar eine Matrix, welche die Position des Eckpunkts (x, y, z) auf den Bildschirm projiziert (x, y)
  • Varying: Varying-Variablen sind Werte, die vom Vertex Shader generiert und an den Pixel Shader übermittelt werden. Im Beispiel überträgt der Vertex Shader einen vUV-Wert (eine einfache Kopie von uv) an den Pixel Shader. Konkret bedeutet das, der Pixel hat eine definierte Position und Koordinaten für die Textur. Diese Werte werden von der GPU interpoliert und vom Pixel Shader verwendet.
  • Main: Diese Funktion ist der Code, den die GPU für jeden Eckpunkt ausführt. Er muss mindestens einen Wert für die gl_position erzeugen (die Position des aktuellen Eckpunkts auf dem Bildschirm).

Der Vertex Shader in unserem Beispiel ist ziemlich einfach aufgebaut. Er erzeugt eine Systemvariable (beginnend mit gl_) namens gl_position, um die Position des damit verbundenen Pixels festzulegen und er legt die Varying-Variable vUV fest.

Die Magie einer Matrix

In unserem Shader-Beispiel gibt es die Matrix worldViewProjection. Wir brauchen sie, um die Position des Eckpunkts auf die gl_position-Variable zu projizieren. Schön und gut, aber woher bekommen wir den Wert für diese Matrix? Nun, es ist eine Uniform-Variable, man muss sie also von der CPU aus definieren (mithilfe von JavaScript).

Das gehört zweifellos zu den eher komplizierten Aufgaben wenn man sich mit 3D beschäftigt. Man muss etwas von komplexer Mathematik verstehen (oder eine 3D Engine wie babylon.js benutzen, mehr dazu später).
Die Matrix worldViewProjection ist eine Kombination von drei verschiedenen Matrizen:

Mit der entstandenen Matrix können 3D-Eckpunkte in 2D-Pixel umgewandelt werden, unter Berücksichtigung der Perspektive und allen Werten bezüglich der Position/des Maßstabs/einer Rotation des Objekts.

Diese Matrix zu erstellen und sie aktuell zu halten – das ist deine Verantwortung als-3D Entwickler.

Zurück zu den Shadern

Nachdem der Vertex Shader auf alle Eckpunkte angewendet wurde (also dreimal), haben wir drei Pixel mit der korrekten gl_position und einem vUV-Wert. Die GPU wird daraufhin diese Werte auf alle Pixel, die das Dreieck enthält (und aus denen es besteht), interpolieren.

Danach wird sie bei jedem Pixel den Pixel Shader anwenden:

precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;

void main(void) {
    gl_FragColor = texture2D(textureSampler, vUV);
}

Die Struktur von Pixel (oder Fragment) Shader

Die Struktur eines Pixel Shaders gleicht der eines Vertex Shaders:

  • Varying: Varying Variablen sind Werte, die vom Vertex Shader generiert und an den Pixel Shader übermittelt werden. Im Beispiel erhält der Pixel Shader einen vUV-Wert vom Vertex Shader.
  • Uniforms: Das sind Variablen, die vom Shader benutzt und von der CPU festgelegt werden. Im Beispiel gibt es nur einen Sampler, ein Werkzeug um die Farben von Texturen zu erkennen.
  • Main: Diese Funktion ist der Code, den die GPU für jeden Eckpunkt ausführt. Er muss mindestens einen Wert für die gl_FragColor erzeugen (die Farbe des aktuellen Pixels).

Dieser Pixel Shader ist sehr einfach: Er erkennt die Farbe der Textur mithilfe der Texturkoordinaten des Vertex Shaders (der sie von den Eckpunkten übermittelt bekam).
Und so sieht der Shader dann aus:

(Der vollständige Code ist auf meinem Blog nachzulesen)

Um dieses Ergebnis zu erzielen, muss man sich mit EINER MENGE WebGL Code herumschlagen. WebGL ist auf der einen Seite ein echt leistungsstarkes Tool, andererseits aber ein API auf niedriger Stufe, bei dem alles selbst gemacht werden muss: Von der Erzeugung der Buffer bis zur Definition der Strukturen von Eckpunkten. Dazu kommen die Berechnung, die Festlegung der Zustände, das Laden der Texturen usw…

Zu schwer? BABYLON.ShaderMaterial kommt zu Hilfe

An dieser Stelle denken sicher viele: Klar, Shader sind echt cool, aber WebGL und der ganze mathematische Hintergrund – dazu habe ich keine Lust, das ist mir echt zu komplex.

Auf jeden Fall! Das ist nur zu verständlich und genau deshalb habe ich ja Babylon.js gebaut.

Was nun folgt, ist der Code auf dem die vorherige Demo mit der rollenden Kugel läuft. Als erstes brauchen wir eine einfache Webseite:

<!DOCTYPE html>
  <html>
  <head>
  <title>Babylon.js</title>
  <script src="Babylon.js"></script>

  <script type="application/vertexShader" id="vertexShaderCode">
  precision highp float; 

  // Attributes
  attribute vec3 position;
  attribute vec2 uv;
        // Uniforms
  uniform mat4 worldViewProjection;
        // Normal
  varying vec2 vUV;
        void main(void) {
  gl_Position = worldViewProjection * vec4(position, 1.0);
        vUV = uv;
  }
  </script>

  <script type="application/fragmentShader" id="fragmentShaderCode">
  precision highp float; 
  varying vec2 vUV;
        uniform sampler2D textureSampler;
        void main(void) {
  gl_FragColor = texture2D(textureSampler, vUV);
  }
  </script>
    <script src="index.js"></script>
  <style>
  html, body {
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
  overflow: hidden;
  margin: 0px;
  overflow: hidden;
  }
        #renderCanvas {
  width: 100%;
  height: 100%;
  touch-action: none;
  -ms-touch-action: none;
  }
  </style>
  </head>
  <body>
  <canvas id="renderCanvas"></canvas>
  </body>
  </html>

Hinweis: Die Shader werden hier durch <script> Tags definiert. In Babylon.js können sie zudem in unterschiedlichen Dateien definiert werden (.fx files).

Babylon.js kann hier heruntergeladen werden oder auf unserem GitHub Repository. Um Zugang zum BABYLON.StandardMaterial zubekommen, wird die Version 1.11 oder höher benötigt.

Der primäre JavaScript Code sieht dann wie folgt aus:

"use strict";

document.addEventListener("DOMContentLoaded", startGame, false);

function startGame() {
    if (BABYLON.Engine.isSupported()) {
        var canvas = document.getElementById("renderCanvas");
        var engine = new BABYLON.Engine(canvas, false);
        var scene = new BABYLON.Scene(engine);
        var camera = new BABYLON.ArcRotateCamera("Camera", 0, Math.PI / 2, 10, BABYLON.Vector3.Zero(), scene);

        camera.attachControl(canvas);

        // Creating sphere
        var sphere = BABYLON.Mesh.CreateSphere("Sphere", 16, 5, scene);

        var amigaMaterial = new BABYLON.ShaderMaterial("amiga", scene, {
            vertexElement: "vertexShaderCode",
            fragmentElement: "fragmentShaderCode",
        },
        {
            attributes: ["position", "uv"],
            uniforms: ["worldViewProjection"]
        });
        amigaMaterial.setTexture("textureSampler", new BABYLON.Texture("amiga.jpg", scene));

        sphere.material = amigaMaterial;

        engine.runRenderLoop(function () {
            sphere.rotation.y += 0.05;
            scene.render();
        });
    }
};

Ich benutze hier BABYLON.ShaderMaterial, um mich weder mit dem Erstellen, dem Verlinken noch der Bearbeitung der Shader direkt beschäftigen zu müssen.

Wer ein BABYLON.ShaderMaterial erstellt, muss das DOM-Element angeben, wo die Shader abgelegt sind oder den Basisnamen der Dateien, in denen sich die Shader befinden. Bei der Nutzung von Dateien muss für jeden Shader nach folgendem Muster eine eigene Datei erzeugt werden: basename.vertex.fx und basename.fragment,.fx. Dann wird das Material wie folgt erstellt:

var cloudMaterial = new BABYLON.ShaderMaterial("cloud", scene, "./myShader",
        {
            attributes: ["position", "uv"],
            uniforms: ["worldViewProjection"]
        });

Außerdem müssen die Namen der benutzten Attributes und Uniforms angegeben werden.
Anschließend können die Werte der Uniforms und Sampler mithilfe der Funktionen setTexture, setFloat, setFloats, setColor3, setColor4, setVector2, setVector3, setVector4, setMatrix  direkt eingestellt werden.

Klingt einfach, oder?

Wer will, kann sich ja noch mal die worldViewProjection-Matrix weiter oben anschauen. Im Vergleich dazu ist die jetzt mit Babylon.js und BABYLON.ShaderMaterial erstellte Version doch das reinste Kinderspiel stimmt´s?! BABYLON.ShaderMaterial berechnet alles automatisch, weil wir es in der Liste der Uniforms so angegeben haben.

BABYLON.ShaderMaterial kann außerdem diese Matrix-Arten verarbeiten:

  • world
  • view
  • projection
  • worldView
  • worldViewProjection

Nein, Mathe braucht man hier nicht mehr. Jedes Mal wenn man zum Beispiel eine sphere.rotation.y += 0.05 veranlasst, wird die world-Matrix der Kugel erstellt und an die GPU übermittelt.

CYOS: Shader selber bauen

Jetzt können wir uns getrost auf den nächsten Level trauen. Wir werden eine Seite bauen, auf der man eigene Shader dynamisch erstellen und das Ergebnis sofort anschauen kann. Dafür nehmen wir den gerade besprochenen Code, die Seite nutzt das BABYLON.ShaderMaterial Objekt um die von uns erstellten Shader zu kompilieren und auszuführen.

Ich habe mit dem ACE Code Editor für CYOS gearbeitet – ein hervorragender Code Editor mit Syntaxmarkierern. Lohnt auf jeden Fall, sich ihn mal hier anzuschauen. Und CYOS gibt es hier.

Mit dem ersten Kombinationsfeld wählt man vorgegebene Shader aus. Welche das sind, sehen wir gleich.

Im zweiten Kombinationsfeld kann man das Gitter (das 3D Objekt) verändern, das uns eine Vorschau der Shader gibt.

Mit dem Compile Button wiederum erstellt man ein neues BABYLON.ShaderMaterial von den Shadern. Dieser Button nutzt folgenden Code:

// Compile
shaderMaterial = new BABYLON.ShaderMaterial("shader", scene, {
    vertexElement: "vertexShaderCode",
    fragmentElement: "fragmentShaderCode",
},
    {
        attributes: ["position", "normal", "uv"],
        uniforms: ["world", "worldView", "worldViewProjection"]
    });

var refTexture = new BABYLON.Texture("ref.jpg", scene);
refTexture.wrapU = BABYLON.Texture.CLAMP_ADDRESSMODE;
refTexture.wrapV = BABYLON.Texture.CLAMP_ADDRESSMODE;

var amigaTexture = new BABYLON.Texture("amiga.jpg", scene);

shaderMaterial.setTexture("textureSampler", amigaTexture);
shaderMaterial.setTexture("refSampler", refTexture);
shaderMaterial.setFloat("time", 0);
shaderMaterial.setVector3("cameraPosition", BABYLON.Vector3.Zero());
shaderMaterial.backFaceCulling = false;

mesh.material = shaderMaterial;

Unglaublich einfach, oder? Das Material ist nun in der Lage drei vorberechnete Matrizen zu übergeben: (world, worldView und worldViewProjection). Den Eckpunkten werden zugleich Position, Normalkoordinaten und Textur-Koordinaten zugeordnet. Zwei Texturen sind dabei bereits geladen:

amiga.jpg:

  • ref.jpg:

Und zu guter Letzt kommt dann noch der renderLoop. Dort aktualisiere ich zwei praktische Uniforms:

  • Eine heißt time und erlaubt uns ein paar lustige Animationen.
  • Die andere ist cameraPosition, um die Kameraposition in die Shader zu bekommen (sehr sinnvoll bei Lichtberechnungen).
engine.runRenderLoop(function () {
    mesh.rotation.y += 0.001;

    if (shaderMaterial) {
        shaderMaterial.setFloat("time", time);
        time += 0.02;

        shaderMaterial.setVector3("cameraPosition", camera.position);
    }

    scene.render();
});

Dank unserer fleißigen Arbeit an Windows Phone 8.1, kann man CYOS auch auf einem Windows Phone benutzen (So kann man Shader erstellen, wo und wann es gerade passt.):

Basic Shader

Beginnen wir also mit dem ersten Shader, der auf CYOS definiert wird: Der Basic Shader.

Den kennen wir schon. Er berechnet die gl_position und benutzt Texturkoordinaten, um für jeden Pixel eine Farbe abzurufen.

Um die Pixelposition zu berechnen brauchen wir die worldViewProjectionMatrix und die Position des Eckpunkts:

precision highp float;

// Attributes
attribute vec3 position;
attribute vec2 uv;

// Uniforms
uniform mat4 worldViewProjection;

// Varying
varying vec2 vUV;

void main(void) {
    gl_Position = worldViewProjection * vec4(position, 1.0);

    vUV = uv;
}

Texturkoordinaten (uv) werden unverändert an den Pixel Shader übermittelt.

Hinweis: Wir müssen “precision mediump float;” in der ersten Zeile hinzufügen, sowohl für den Vertex Shader als auch für den Pixel Shader; das ist für Chrome erforderlich. Um eine bessere Performance zu erreichen, wird damit festgelegt, dass keine Gleitkommawerte mit vollständiger Präzision benutzt werden.

Der Pixel Shader ist sogar noch einfacher, weil wir nur die Texturkoordinaten brauchen und die Texturfarbe abrufen:

precision highp float;

varying vec2 vUV;

uniform sampler2D textureSampler;

void main(void) {
    gl_FragColor = texture2D(textureSampler, vUV);
}

Wir haben bereits gesehen, dass das uniform-Element textureSampler mit der Textur “amiga” gefüllt ist. Daher sieht das Ganze dann so aus:

Schwarz/Weiß Shader

Nun zum nächsten Shader: den Schwarz/Weiß Shader.

Dieser Shader basiert auf dem vorhergehenden, soll jedoch ausschließlich im Schwarz/Weiß Rendering Modus arbeiten.
Wir nehmen also den Vertex Shader, den wir schon haben. Nur den Pixel Shader werden wir leicht modifizieren.

Zunächst beschränken wir uns auf nur eine Komponente, in diesem Fall hier die grüne:

precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;
void main(void) {
    gl_FragColor = vec4(texture2D(textureSampler, vUV).ggg, 1.0);
}

Deutlich zu sehen: Statt .rgb (diese Funktion nennt man Swizzle) haben wir .ggg benutzt.

Um aber einen wirklich korrekten Schwarz/Weiß-Effekt zu erzielen, sollten wir außerdem die Helligkeit berechnen (dabei alle Komponenten in Betracht ziehend):

precision highp float;

varying vec2 vUV;

uniform sampler2D textureSampler;

void main(void) {
    float luminance = dot(texture2D(textureSampler, vUV).rgb, vec3(0.3, 0.59, 0.11));
    gl_FragColor = vec4(luminance, luminance, luminance, 1.0);
}

Die Punktfunktion (oder das Punktprodukt) wird folgendermaßen berechnet:

result = v0.x * v1.x + v0.y * v1.y + v0.z * v1.z

In unserem Beispiel also:

luminance = r * 0.3 + g * 0.59 + b * 0.11 (Diese Werte berücksichtigen, dass das menschliche Auge Grüntöne besser wahrnimmt.)

Na wenn das nicht ziemlich cool klingt, oder?

Cel Shading Shader

Kommen wir jetzt zu einem etwas komplexeren Shader, dem Cel Shading Shader (fälschlicherweise oft als „cell shading“ geschrieben).

Bei diesem brauchen wir die Normalkoordinaten und die Positionen der Eckpunkte im Pixel Shader. Demzufolge sieht der Vertex Shader so aus:

precision highp float;

// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

// Uniforms
uniform mat4 world;
uniform mat4 worldViewProjection;

// Varying
varying vec3 vPositionW;
varying vec3 vNormalW;
varying vec2 vUV;

void main(void) {
    vec4 outPosition = worldViewProjection * vec4(position, 1.0);
    gl_Position = outPosition;

    vPositionW = vec3(world * vec4(position, 1.0));
    vNormalW = normalize(vec3(world * vec4(normal, 0.0)));

    vUV = uv;
}

Hinweis: Wir benutzen hier auch die world-Matrix, weil Position und Normalkoordinaten ohne Umwandlung gespeichert sind. Die Anwendung der world-Matrix erlaubt es uns, die Rotation des Objekts mit einzuberechnen.

Der Pixel Shader sieht dann wie folgt aus:

precision highp float;

// Lights
varying vec3 vPositionW;
varying vec3 vNormalW;
varying vec2 vUV;

// Refs
uniform sampler2D textureSampler;

void main(void) {
    float ToonThresholds[4];
    ToonThresholds[0] = 0.95;
    ToonThresholds[1] = 0.5;
    ToonThresholds[2] = 0.2;
    ToonThresholds[3] = 0.03;

    float ToonBrightnessLevels[5];
    ToonBrightnessLevels[0] = 1.0;
    ToonBrightnessLevels[1] = 0.8;
    ToonBrightnessLevels[2] = 0.6;
    ToonBrightnessLevels[3] = 0.35;
    ToonBrightnessLevels[4] = 0.2;

    vec3 vLightPosition = vec3(0, 20, 10);

    // Light
    vec3 lightVectorW = normalize(vLightPosition - vPositionW);

    // diffuse
    float ndl = max(0., dot(vNormalW, lightVectorW));

    vec3 color = texture2D(textureSampler, vUV).rgb;

    if (ndl > ToonThresholds[0])
    {
        color *= ToonBrightnessLevels[0];
    }
    else if (ndl > ToonThresholds[1])
    {
        color *= ToonBrightnessLevels[1];
    }
    else if (ndl > ToonThresholds[2])
    {
        color *= ToonBrightnessLevels[2];
    }
    else if (ndl > ToonThresholds[3])
    {
        color *= ToonBrightnessLevels[3];
    }
    else
    {
        color *= ToonBrightnessLevels[4];
    }

    gl_FragColor = vec4(color, 1.);
}

Aufgabe dieses Shaders ist es, eine Lichtquelle zu simulieren. Dabei wird auf weich verlaufende Schattierungen verzichtet, stattdessen kommen Helligkeitsstufen zum Einsatz. Ist die Lichtintensität zum Beispiel zwischen 1 (Maximum) und 0,95, wird die Farbe des Objekts (abgerufen von der Textur) direkt angewendet. Liegt die Intensität zwischen 0,95 und 0,5, wird die Farbe um den Faktor 0,8 abgeschwächt usw.

Diesen Shader erhalten wir in vier Schritten:

  • Zuerst geben wir Helligkeitsstufen und Levelkonstanten an.
  • Dann berechnen wir mithilfe der Phong-Gleichung die Lichtwerte (Wir gehen dabei davon aus, dass sich die Lichtquelle nicht bewegt.):
vec3 vLightPosition = vec3(0, 20, 10);

// Light
vec3 lightVectorW = normalize(vLightPosition - vPositionW);

// diffuse
float ndl = max(0., dot(vNormalW, lightVectorW));

Die Lichtintensität pro Pixel ist abhängig vom Winkel zwischen den Normalkoordinaten und der Lichtrichtung.

  • Anschließend erhalten wir die Texturfarbe für die Pixel
  • Zum Schluss kontrollieren wir die Helligkeitsstufen und wenden den jeweiligen Level auf die Farbe an.

Das sieht dann ein bisschen aus wie ein Cartoon-Objekt:

Phong Shader

Im vorherigen Shader haben wir einen Teil der Phong-Gleichung benutzt. Jetzt nehmen wir einfach mal die ganze Gleichung.

Der Vertex Shader ist leicht, weil die Action ausschließlich im Pixel Shader stattfindet:

precision highp float;

// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

// Uniforms
uniform mat4 worldViewProjection;

// Varying
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUV;

void main(void) {
    vec4 outPosition = worldViewProjection * vec4(position, 1.0);
    gl_Position = outPosition;

    vUV = uv;
    vPosition = position;
    vNormal = normal;
}

Gemäß der Gleichung müssen wir die diffuse Reflexion und die Spekularität mithilfe der Lichtrichtung und den Normalkoordinaten der Eckpunkte berechnen:

precision highp float;

// Varying
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUV;

// Uniforms
uniform mat4 world;

// Refs
uniform vec3 cameraPosition;
uniform sampler2D textureSampler;

void main(void) {
    vec3 vLightPosition = vec3(0, 20, 10);

    // World values
    vec3 vPositionW = vec3(world * vec4(vPosition, 1.0));
    vec3 vNormalW = normalize(vec3(world * vec4(vNormal, 0.0)));
    vec3 viewDirectionW = normalize(cameraPosition - vPositionW);

    // Light
    vec3 lightVectorW = normalize(vLightPosition - vPositionW);
    vec3 color = texture2D(textureSampler, vUV).rgb;

    // diffuse
    float ndl = max(0., dot(vNormalW, lightVectorW));

    // Specular
    vec3 angleW = normalize(viewDirectionW + lightVectorW);
    float specComp = max(0., dot(vNormalW, angleW));
    specComp = pow(specComp, max(1., 64.)) * 2.;

    gl_FragColor = vec4(color * ndl + vec3(specComp), 1.);
}

Die diffuse Reflexion hatten wir schon im Shader davor, wir müssen also nur noch den Spiegeleffekt hinzufügen. Diese Illustration eines Wikipedia-Artikels zeigt sehr gut, wie der Shader letztlich funktioniert:

Bei unserer Kugel sieht das so aus:

Discard Shader

Für den Discard Shader stelle ich an dieser Stelle ein neues Konzept vor: Das Discard Keyword.

Dieser Shader wird jeden nicht-roten Pixel verwerfen und ein Dug-Objekt vortäuschen.

Der Vertex Shader ist der gleiche wie am Anfang beim Basic Shader:

precision highp float;

// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

// Uniforms
uniform mat4 worldViewProjection;

// Varying
varying vec2 vUV;

void main(void) {
    gl_Position = worldViewProjection * vec4(position, 1.0);

    vUV = uv;
}

Der Pixel Shader prüft die Farbe und nutzt die Discard-Funktion, wenn zum Beispiel die grüne Komponente zu hoch ist:

precision highp float;

varying vec2 vUV;

// Refs
uniform sampler2D textureSampler;

void main(void) {
    vec3 color = texture2D(textureSampler, vUV).rgb;

    if (color.g > 0.5) {
        discard;
    }

    gl_FragColor = vec4(color, 1.);
}

Das sieht dann etwas seltsam aus:

Wave Shader

Nachdem wir mit dem Pixel Shader unseren Spaß hatten, will ich nun zeigen, dass auch der Vertex Shader eine Menge kann.

Für den Wave Shader werden wir den Phong Pixel Shader wiederverwenden.

Der Vertex Shader benutzt das uniform-Element time um einige Animationswerte zu erhalten. Dadurch wird der Shader eine Welle mit den Positionswerten der Eckpunkte erzeugen:

precision highp float;

// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

// Uniforms
uniform mat4 worldViewProjection;
uniform float time;

// Varying
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUV;

void main(void) {
    vec3 v = position;
    v.x += sin(2.0 * position.y + (time)) * 0.5;

    gl_Position = worldViewProjection * vec4(v, 1.0);

    vPosition = position;
    vNormal = normal;
    vUV = uv;
}

Nachdem eine Sinuskurve auf die position.y angewendet wird, erhalten wir dieses Ergebnis:

Spherical Environment Mapping

Diese Variante wurde größtenteils von diesem Tutorial inspiriert. Ein wirklich exzellenter Artikel, den es sich zu lesen lohnt, genau wie das Experimentieren mit dem dazugehörigen Shader.

Fresnel Shader

Zum Abschluss will ich auch noch meinen Favoriten vorstellen: den Fresnel Shader.
Dieser Shader variiert die Intensität abhängig vom Winkel zwischen der Blickrichtung und den Normalkoordinaten der Eckpunkte.

Der Vertex Shader ist der gleiche, den wir für den Cel Shading Shader benutzt haben und es nicht schwer, die Fresnel Reflexion in unseren Pixel Shader hineinzurechnen (Wir haben schließlich die Normalkoordinaten und die Kameraposition, um die Blickrichtung zu beurteilen):

precision highp float;

// Lights
varying vec3 vPositionW;
varying vec3 vNormalW;

// Refs
uniform vec3 cameraPosition;
uniform sampler2D textureSampler;

void main(void) {
    vec3 color = vec3(1., 1., 1.);
    vec3 viewDirectionW = normalize(cameraPosition - vPositionW);

    // Fresnel
    float fresnelTerm = dot(viewDirectionW, vNormalW);
    fresnelTerm = clamp(1.0 - fresnelTerm, 0., 1.);

    gl_FragColor = vec4(color * fresnelTerm, 1.);
}

Oder doch einen ganz anderen Shader?

Genug der Vorbereitung, jetzt kann jeder daran gehen, einen eigenen Shader zu bauen. Eigene Erfahrungen können gern hier im Kommentarfeld oder im babylon.js Forum geteilt werden (Link s. unten).

Wer sich noch intensiver mit dem Thema beschäftigen will, hier ein paar nützliche Links:

Und hier noch ein bisschen Lehrmaterial von meiner Seite:

Oder, wer noch einen Schritt zurückgehen will – die Schulungsreihe über JavaScript von unserem Team:

Und jeder ist natürlich eingeladen, unsere kostenlosen Werkzeuge für sein nächstes Web-Projekt zu nutzen: Visual Studio Community, Azure Trial und Cross-Browser Testwerkzeuge für Mac, Linux oder Windows.

Dieser Artikel ist Teil der Web Dev Tech Series von Microsoft. Wir freuen uns, das Microsoft Edge und seine neue EdgeHTML Rendering Engine mit euch zu teilen. Kostenlose Virtual Machines oder Remote Testings für Mac, iOS, Android oder Windows gibt es hier: dev.modern.IE.

David Catuhe

David Catuhe ist Principal Program Manager bei Microsoft mit einem Fokus auf Web-Entwicklung. Er ist der Autor des babylon.js Frameworks, mit dem man 3D Spiele mit HTML5 and WebGL erstellen kann. Lesen Sie seinen Blog auf MSDN oder folgen Sie ihm auf Twitter: @deltakosh

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.