The Book of Shaders by Patricio Gonzalez Vivo & Jen Lowe

日本語 - 中文版 - 한국어 - Español - Français - Italiano - Deutsch - English


Alice Hubbard, Providence, USA, ca. 1892. Foto: Zindman/Freemont.

Formen

Endlich! Auf diesen Moment haben wir in den vorangegangenen Kapiteln hingearbeitet! Du hast die wichtigsten Grundlagen von GLSL, seine Datentypen und Funktionen kennengelernt. Und du hast mit formgebenden Funktionen gearbeitet. Jetzt ist es an der Zeit, all dieses Wissen zusammenzuführen. Bist Du bereit? In diesem Kapitel wirst Du lernen, wie man grundlegende geometrische Formen auf parallele, prozedurale Weise erstellt.

Rechtecke

Stell dir vor, Du hast ein Millimeterpapier vor Dir, wie man es zuweilen im Mathematikunterricht nutzt. Und Deine Aufgabe ist es, darauf ein ausgefülltes Quadrat zu zeichnen. Die Größe des Papieres beträgt 10 x 10 und das Quadrat soll 8 x 8 sein. Wie gehst Du vor?

Vielleicht würdest Du einfach die gesamte Fläche ausfüllen und dabei nur die erste und die letzte Zeile auslassen, ebenso wie die erste und die letzte Spalte. So käme das gewünschte Quadrat sehr einfach zustande.

Wie hängt das nun mit Shadern zusammen? Jedes kleine Quadrat auf unserem Millimeterpapier können wir uns als ein Pixel, d.h. einen parallel ausgeführten Thread, vorstellen. Und jedes dieser Quadrate (jeder Thread) kennt seine Koordinate, wie bei einem Schachbrett. In den vorangegangenen Kapiteln haben wir die x- und die y-Ordinate jeweils auf den roten und grünen Farbkanal des Punktes abgebildet. Wir haben gelernt, wie wir den schmalen zweidimensionalen Bereich zwischen 0.0 und 1.0 bearbeiten. Wie können wir dieses Wissen nun anwenden, um in der Mitte unserer Zeichenfläche ein zentriertes weißes Quadrat zu malen?

Lass uns mit etwas Pseudocode beginnen, der mit if-Befehlen auf die Lage des zu bearbeitenden Pixels innerhalb der Zeichenfläche eingeht. Das Vorgehen ähnelt dabei in bemerkenswerte Weise dem oben skizzierten beim Malen auf Millimeterpapier.

    if ( (X GROESSER ALS 1) UND (Y GROESSER ALS 1) )
        male weiss
    else 
        male schwarz

Jetzt, wo wir eine Idee haben, wie die Lösung aussehen könnte, lass uns den if-Befehl durch einen Aufruf der step()-Funktion ersetzen, und anstelle der Maße 10 x 10 normalisierte Werte zwischen 0.0 und 1.0 bearbeiten:

uniform vec2 u_resolution;

void main(){
    vec2 st = gl_FragCoord.xy/u_resolution.xy;
    vec3 color = vec3(0.0);

    // diese Berechnungen liefern jeweils 1.0 (weiss) oder 0.0 (schwarz).
    float left = step(0.1,st.x);   // entspricht X groesser als 0.1
    float bottom = step(0.1,st.y); // entspricht Y groesser als 0.1

    // die Multiplikation von left*bottom entspricht der logischen Verknüpfung durch UND
    color = vec3( left * bottom ); 

    gl_FragColor = vec4(color,1.0);
}

Die step()-Funktion setzt jedes Pixel unterhalb von 0.1 auf Schwarz (vec3(0.0)) und alle anderen auf Weiß (vec3(1.0)). Die Multiplikation von left und bottom arbeitet wie eine logische UND-Verknüpfung, weil nur dann nicht 0.0, sondern 1.0 herauskommt, wenn beide Variablen auf 1.0 stehen. So entstehen zwei schwarze Linien, eine am linken und eine am unteren Rand der Zeichenfläche. Der Rest wird weiß.

Im obigen Programmcode wiederholen wir die gleiche Vorgehensweise für beide Ränder (links und unten). Wir können das noch etwas kompakter formulieren, indem wir in einem Aufruf zwei Testwerte in Form eines zweidimensionalen Vektors an step() übergeben. Das sieht dann so aus:

    vec2 borders = step(vec2(0.1),st); 
    float pct = borders.x * borders.y;

Bis jetzt haben wir nur zwei Kanten unseres Rechtecks bearbeitet. Jetzt kommen auch die beiden anderen Kanten – die rechte und die obere – an die Reihe. Schau Dir den folgenden Programmcode an:

Entferne die Kommentarzeichen aus den Zeilen 21-22 und beobachte, wie wir die Koordinaten auf den linken und unteren Rand abbilden (1-st), damit wir sie wieder mit der step() Funktion und dem Wert von 0.1 vergleichen können. Aus der oberen rechten Ecke (vec2(1.0,1.0)) wird so für unsere Berechnungen quasi die untere linke Ecke vec2(0.0,0.0). Das ist so, als würden wir die Zeichenfläche einfach um 180 Grad drehen und den Test dann wie zuvor wiederholen.

Beachte außerdem, dass in den Zeilen 18 und 22 die Ergebnisse von step() in Bezug auf alle vier Seiten miteinander multipliziert werden. Dies entspricht wiederum eine logischen UND-Verknüpfung, denn wir erhalten nur dann Weiß (1.0), wenn keines der Ergebnisse Schwarz (0.0) ist. Wir könnten auch schreiben:

    vec2 bl = step(vec2(0.1),st);       // linke und untere Kante
    vec2 tr = step(vec2(0.1),1.0-st);   // rechte und obere Kante
    color = vec3(bl.x * bl.y * tr.x * tr.y);  //UND-Verknuepfung

Interessant, nicht wahr? Wir nutzen für diese Technik also step() zum Vergleichen, eine Subtraktion für die Drehung der Koordinaten und die Multiplikation als logische UND-Verknüpfung.

Bevor wir weitermachen, probiere bitte die folgenden Übungen aus:

Piet Mondrian - Tableau (1921)

Kreise

Es ist nicht schwer, Quadrate auf Millimeterpapier zu zeichnen und Rechtecke mit Hilfe kartesischer Koordinaten zu konstruieren. Kreise verlangen jedoch einen anderen Ansatz, zumal wie hier einen Pixel-orientierten Algorithmus benötigen. Eine Lösung besteht darin, die Koordinaten zu transformieren, damit wir beim Zeichnen die step()-Funktion nutzen können.

Doch wie soll das funktionieren? Lasse uns noch einmal zum Mathematikunterricht und dem Millimeterpapier zurückkehren. In der Schule haben die meisten von uns vermutlich gelernt, wie man mit dem Zirkel einen Kreis malt: Über das Drehrad stellt man zunächst den gewünschten Radius ein, platziert den Zirkel im Mittelpunkt des zu zeichnenden Kreises und schwingt das Zeichengerät anschließend mit einer eleganten Drehung über das Papier.

Will man diese Vorgehensweise auf ein Shader-Programm übertragen, bei dem jedes kleine Feld auf dem Millimeterpapier einem Pixel entspricht, muss man jedes Pixel (bzw. Thread) fragen, ob es zum Kreis gehört. Das machen wir, indem wir die Entfernung des Pixels zum Mittelpunkt des gewünschten Kreises berechnen.

Tatsächlich gibt es in GLSL mehrere Wege, diese Entfernung zu bestimmen. Die einfachste Möglichkeit greift auf die distance()-Funktion zurück, die intern den Abstand (die Länge) length() zwischen zwei Punkten berechnet. In unserem Fall werden diese beiden Punkte durch die aktuelle Pixel-Koordinate sowie die Mitte der Zeichenfläche verkörpert, die hier den Kreismittelpunkt bilden soll. Die length()-Funktion ist nichts anderes als eine Umsetzung der Hypothenuse-Formel, die intern die Quadratwurzel (sqrt()) berechnet.

Man kann wahlweise die distance()-Funktion, die length()-Funktion oder die sqrt()-Funktion nutzen, um die Entfernung zur Mitte der Zeichenfläche zu berechnen. Der folgende Programmcode enthält alle drei Möglichkeiten und liefert erwartungsgemäß jeweils das gleiche Ergebnis zurück.

In dem obigen Beispiel bilden wir die Entfernung zum Mittelpunkt der Zeichenfläche auf die Helligkeit der Pixel ab. Je näher sich ein Pixel beim Mittelpunkt befindet, desto geringer (dunkler) ist sein Farbwert. Beachte bitte, dass die Pixel auch zum Rand hin nicht allzu hell werden, weil die Entfernung vom Mittelpunkt ( vec2(0.5, 0.5) ) zu den Rändern maximal 0.5 beträgt. Denke ein wenig über die Abbildung nach und überlege Dir:

Distanzfelder

Man kann sich das obige Beispiel auch als eine Art Höhenprofil vorstellen, bei dem dunklere Stellen für größere Höhen stehen. Der Farbverlauf repräsentiert dann so etwas wie einen Kegel. Stell Dir vor, Du stehst auf der Spitze des Kegels. Die horizontale Entfernung zum Kegelrand beträgt in alle Richtungen jeweils 0.5. Indem Du den Kegel an einer gewählten Stelle abschneidest, erhältst Du je nachdem eine größere oder eine kleinere Kreisfläche.

Im Prinzip nutzen wir also eine Neuinterpretation des Raumes (ausgehend vom Abstand zur Mitte), um eine bestimmte Form zu kreieren. Diese Technik ist als „Distanzfeld“ bekannt und wird bei der Erstellung von 3D-Grafiken auf vielfältige Weise genutzt.

Versuche Dich doch einmal an folgenden Übungen:

pct = distance(st,vec2(0.4)) + distance(st,vec2(0.6));
pct = distance(st,vec2(0.4)) * distance(st,vec2(0.6));
pct = min(distance(st,vec2(0.4)),distance(st,vec2(0.6)));
pct = max(distance(st,vec2(0.4)),distance(st,vec2(0.6)));
pct = pow(distance(st,vec2(0.4)),distance(st,vec2(0.6)));

Für Deine Werkzeugsammlung

Im Hinblick auf die erforderliche Rechenleistung kann die sqrt()-Funktion – und alle Funktionen, die darauf basieren – sehr kostspielig sein. Deshalb hier nun ein anderer Weg, wie man kreisförmige Distanzfelder mit Hilfe der dot()-Funktion über das Skalarprodukt erzeugen kann.

Nützliche Eigenschaften von Distanzfeldern

Zen garden

Distanzfelder lassen sich nutzen, um beinahe jede Art von Form zu zeichnen. Je komplexer die gewünschte Form, desto komplexer fällt in der Regel auch die dafür benötigte Distanzformel aus. Doch sobald Du die benötigte Distanzformel beisammenhast, wird es sehr einfach, gewünschte Effekte darauf anzuwenden, beispielweise weiche Kanten oder mehrfache Umrisse. Aus diesem Grund sind Distanzfelder u.a. bei der Schriftenerzeugung sehr populär, nachzulesen etwa bei Mapbox GL Labels, bei Matt DesLauriers und bei Material Design Fonts.

Schau Dir den folgenden Programmcode an.

Wir beginnen, indem wir den Wertebereich unserer x- und y-Ordinate auf die Spanne zwischen -1 und 1 skalieren und damit gleichzeitig die Basis des Koordinatensystems (0/0) in die Mitte der Zeichenfläche verschieben. In der Zeile 24 visualisieren wir die Werte unseres Distanzfeldes, indem wir sie mit zehn multiplizieren und die fract()-Funktion auf das Ergebnis anwenden. fract() liefert immer nur den Nachkommateil des Ergebnisses, also eine Zahl zwischen 0.0 und 0.99999..., so dass bei den wiederholten Aufrufe für die verschiedenen Pixel ein Farbverlauf von Schwarz nach Weiß entsteht. Durch die Multiplikation mit zehn wiederholt sich dieser Verlauf genau zehn Mal. So entsteht ein repetitives Kreismuster, wie bei einem Zen-Garten.

Lass uns einen Blick auf die Formel zur Berechnung des Distanzfeldes in der Programmzeile 19 werfen. Dort berechnen wir den Abstand zur Position (.3,.3). Damit dies in allen vier Quadranten gleichermaßen geschieht, wird der zu bearbeitende Punkt jeweils auf den ersten Quadranten abgebildet. Dafür sorgt hier die abs()-Funktion.

Wenn Du die Kommentarzeichen in Programmzeile 20 entfernst, kannst Du beobachten, wie wir die Entfernung bei allen Punkten um 0.3 reduzieren. Anschließend setzen wir den Abstand mit Hilfe der min()-Funktion für alle Punkte, deren Abstand dann noch größer als 0.0 ist (also vorher größer als 0.3 war), auf 0.0. Das erzeugt ein neues interessantes Muster.

Sobald Du anschließend die Kommentarzeichen aus der Programmzeile 21 entfernst, geschieht etwas ähnliches, nur quasi umgekehrt und mit Hilfe der Funktion max(). Wieder wird der Abstand um 0.3 verringert, anschließend aber für alle Punkte auf 0.0 gesetzt, deren Abstand nun kleiner als 0.0 ist (vorher also zwischen 0.0 und 0.3 lag). Wir erhalten auf diese Weise mehrere geschachtelte Quadrate mit abgerundeten Ecken.

Entferne nun nach und nach die Kommentarzeichen aus den Programmzeilen 27 bis 29, um deren Auswirkung auf das Zeichnen mit dem Distanzfeld zu verstehen.

Polarformen

Robert Mangold – Ohne Titel (2008)

Im Kapitel über die Verwendung von Farben haben wir kartesische Koordinaten auf Polarkoordinaten abgebildet. Wir berechneten dazu den Radius und den Winkel jeder Koordinate mit Hilfe der folgenden Formel:

    vec2 pos = vec2(0.5)-st;
    float r = length(pos)*2.0;
    float a = atan(pos.y,pos.x);

Einige dieser Formeln haben wir auch am Anfang dieses Kapitels genutzt, als es darum ging, Kreise zu zeichnen. Wir berechneten die Entfernung zum Kreismittelpunkt mit Hilfe der length()-Funktion. Jetzt, wo wir Distanzfelder kennengelernt haben, öffnet sich uns ein weiterer Weg zum Zeichnen komplexer Formen mithilfe von Polarkoordinaten.

Diese Technik unterliegt gewissen Beschränkungen, ist dafür aber sehr simpel und leistungsfähig. Sie beruht darauf, den Radius eines Kreises in Abhängigkeit des jeweiligen Winkels zu verändern, um unterschiedliche Formen zu erschaffen. Wie genau läuft diese Modulierung ab? Nun, Du hast es vielleicht schon erraten: Mit formgebenden Funktionen.

Unten findest Du verschiedene Funktionen jeweils zwei Mal: einmal als Verlaufskurve in einem kartesischen Koordinatensystem und dann als Shader-Programmcode in einem polaren Koordinatensystem. Dort stehen die verschiedenen formgebenden Funktionen in den Programmzeilen 21 bis 25. Entferne nun Schritt für Schritt die Kommentarzeilen und vergleiche den jeweiligen Funktionsgraphen im kartesischen Koordinatensystem mit seinem Äquivalent beim Zeichnen innerhalb eines Polarkoordinatensystem mit GLSL.

Versuche doch einmal:

Und nun alles zusammen

Wir haben gelernt, den Radius einer Kreisform mit Hilfe der atan()-Funktion in Abhängigkeit des Winkels für das Zeichnen unterschiedlicher Formen zu nutzen. Nun können wir atan() auch mit Distanzfeldern einsetzen, um ganz unterschiedliche Effekte zu erzielen.

Unser Trick nutzt die gegebene Anzahl der Seiten eines Polygons, um das benötigte Distanzfeld mit Hilfe von Polarkoordinaten zu erzeugen. Schau Dir dazu auch den folgenden Programmcode von Andrew Baldwin an.

Herzlichen Glückwunsch! Du hast Dich durch schwieriges Fahrwasser gekämpft. Nimm eine kleine Pause, damit sich das Erlernte setzen kann. Das Zeichnen komplexer Formen im Land der Shader ist wahrlich nicht ganz trivial, das kann durchaus ein wenig erschöpfen.

Da Du nun weißt, wie man unterschiedliche Formen zeichnet, kommen Dir bestimmt viele interessante Ideen in den Sinn. In den folgenden Kapiteln lernen wir, wie man Formen verschieben, skalieren und rotieren kann. Das wird Dir ermöglichen, komplexe Kompositionen zu erstellen.