Ein Shader ist ein kleines Programm, das die Grafikkarte für jeden Pixel gleichzeitig ausführt. Du schreibst Shader in GLSL, so wie auf Shadertoy: einen Farbverlauf, einen Kreis, leuchtende Kreise in Bewegung, ein Kaleidoskop, Wolken aus Rauschen, Tropfen, die in einem dunklen Studio ineinanderfließen und sich im schwarzen Boden spiegeln, und am Ende einen Würfel mit Löchern in Löchern, durch dessen endlose Gänge du fliegst. Es sind Strahlen wie im Raytracer, aber sie finden Formen, für die der Raytracer keine Formel hätte, und die Grafikkarte rechnet viele Bilder in der Sekunde.

So sieht dein letzter Shader aus, und er bewegt sich: Die Kamera fliegt langsam und schief durch die Gänge eines Würfels, der aus Löchern in Löchern besteht, von Raum zu Raum, und nur in manchen brennt ein Licht. Das ganze Programm hat gut hundert Zeilen.
Bis hierher hat in der Hackschule bei Grafik immer der Prozessor gerechnet, ein Pixel nach dem anderen: in Echtzeitgrafik mit C in einer Schleife über alle Pixel, im Raytracer Durchgang für Durchgang, bis ein Bild glatt war. Jeder Rechner hat aber einen zweiten eingebaut, den Grafikchip. In Schulrechnern und Laptops sitzt er meist mit im Prozessor, und trotzdem hat er über hundert kleine Rechenwerke, eine große Grafikkarte Tausende. Sie führen alle dasselbe Programm aus, jedes für einen anderen Pixel, alle zugleich.
Dieses Programm heißt Shader, und du schreibst nur seinen Inhalt: was für einen Pixel passiert. Die Schleife über alle Pixel gibt es nicht mehr, der Grafikchip startet dein Programm für jeden Pixel selbst. Dafür weiß ein Pixel nichts von seinen Nachbarn und kann sich nichts merken: Er kennt nur seine eigene Position, die Größe des Bildes, die Uhrzeit und die Maus.
Shader schreibt man in GLSL, einer Sprache, die aussieht wie C. Du schreibst sie so wie auf Shadertoy, einer Seite, auf der Zehntausende Shader zum Ansehen und Lernen stehen; Pol Jeremias und Íñigo Quílez haben sie 2013 gestartet. Vorwissen brauchst du nicht. Wer Echtzeitgrafik mit C oder den Raytracer kennt, wird vieles wiedererkennen. Die Typen und Funktionen von GLSL stehen im Handbuch unter GLSL.
Für diese Anleitung brauchst du die Vorlage des GLSL-Starters:
Stelle zuerst sicher, dass du keinen Ordner geöffnet hast. Um sicherzugehen, drücke einfach den Shortcut für »Ordner schließen«: StrgK und dann F. Dein Workspace sollte jetzt ungefähr so aussehen:

Klicke auf »Clone Repository« und füge die Adresse der Vorlage ein:
https://github.com/specht/glsl-starter.git

Bestätige mit Enter. Danach wählst du das Verzeichnis, in dem das Repository gespeichert werden soll: den Ordner /workspace.

Das Repository wird jetzt in einen Unterordner von /workspace geklont, der so heißt wie das Repository. Beantworte die Frage, ob es jetzt geöffnet werden soll, mit »Open«:

Im Explorer siehst du jetzt den Ordner glsl-starter. Wichtig sind zwei Dateien darin: index.html zeigt einen Shader im ganzen Fenster, und shader.frag ist der Shader. An index.html änderst du nichts; du schreibst nur in shader.frag. Öffne die Datei mit einem Klick.


Klicke unten rechts in der Statusleiste auf »Go Live«. Es öffnet sich ein neuer Tab mit der Vorschau, und dort läuft dein Shader:

Ein Farbverlauf, und er lebt: Das Blau wird langsam stärker und schwächer. Jedes Mal, wenn du shader.frag speicherst, lädt die Vorschau neu und zeigt deine Änderung.
shader.frag.
So sieht shader.frag aus:
// Dein Shader: mainImage rechnet für jeden Pixel seine Farbe aus. // fragCoord ist die Position des Pixels, iResolution die Größe des Bildes, // iTime die Zeit in Sekunden. void mainImage(out vec4 fragColor, in vec2 fragCoord) { // uv läuft von 0 (links unten) bis 1 (rechts oben) vec2 uv = fragCoord / iResolution.xy; // Rot von links nach rechts, Grün von unten nach oben, Blau pulsiert vec3 color = vec3(uv.x, uv.y, 0.5 + 0.5 * sin(iTime)); fragColor = vec4(color, 1.0); }
Die Funktion mainImage läuft für jeden Pixel einmal:
fragCoord ist die Position des Pixels, in Pixeln gezählt, von links unten, nicht wie bei PixelRAM von links oben. Es ist ein vec2: zwei Kommazahlen, fragCoord.x und fragCoord.y.iResolution ist die Größe des Bildes in Pixeln. iResolution.xy nimmt davon die ersten beiden Zahlen, Breite und Höhe.fragCoord / iResolution.xy teilt beide Zahlen gleichzeitig, jede durch ihre eigene. uv läuft so in beiden Richtungen von 0 bis 1, egal wie groß das Fenster ist.vec3 aus Rot, Grün und Blau, jedes von 0 bis 1. fragColor bekommt eine vierte Zahl dazu, die Deckkraft, hier 1.iTime sind die Sekunden seit dem Start. sin(iTime) schwingt zwischen −1 und 1, 0.5 + 0.5 * sin(iTime) also zwischen 0 und 1.Im Handbuch steht vor jedem Shader noch #version, precision und eine Liste der uniform-Variablen. Das schreibt index.html für dich davor, so wie Shadertoy, und dahinter ein main, das mainImage aufruft. Kommazahlen schreibst du in GLSL immer mit Punkt: 1.0, nicht 1.
In der Ecke links unten ist uv = (0, 0). Welche Farbe hat sie in dem
Augenblick, in dem sin(iTime) gerade −1 ist? Überleg erst, dann sieh
in der Vorschau eine Weile auf diese Ecke.
uv die Zeile uv = fract(uv * 4.0); und speichere. fract behält von einer Zahl nur, was hinter dem Komma steht. Achte darauf, wie oft der Verlauf jetzt im Bild steht und warum er an den Kanten springt. Nimm die Zeile danach wieder heraus.
Einen Kreis zeichnet ein Shader nicht, er fragt jeden Pixel: Wie weit bist du von der Mitte weg? Ersetze den Inhalt von shader.frag durch dieses Programm:
// Ein Kreis: Jeder Pixel fragt, wie weit er von der Mitte weg ist. void mainImage(out vec4 fragColor, in vec2 fragCoord) { // p: die Mitte ist (0, 0), oben ist 1, unten -1; nach links und rechts // so weit, wie das Bild breit ist, damit ein Kreis rund bleibt vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // so groß ist ein Pixel in diesen Koordinaten float pixel = 2.0 / iResolution.y; // der Abstand zur Mitte float d = length(p); // 1 im Kreis, 0 draußen, dazwischen ein weicher Rand über zwei Pixel float inside = 1.0 - smoothstep(0.5 - pixel, 0.5 + pixel, d); vec3 background = vec3(0.08, 0.1, 0.18) + 0.08 * p.y; vec3 disk = vec3(1.0, 0.6, 0.2); fragColor = vec4(mix(background, disk, inside), 1.0); }

p ist die Position des Pixels, so verschoben, dass die Mitte des Bildes (0, 0) ist, und durch die Höhe geteilt. Oben ist dann 1, unten −1, und nach links und rechts reicht p.x so weit, wie das Bild breiter als hoch ist. Weil beide Richtungen durch dieselbe Zahl geteilt werden, bleibt ein Kreis rund. Der Raytracer rechnet in ray_dir genauso.
length(p) ist der Abstand zur Mitte. Wer näher als 0,5 ist, gehört zum Kreis. Ein hartes if (d < 0.5) gäbe Treppenstufen am Rand. smoothstep(a, b, d) ist 0, solange d kleiner als a ist, 1 ab b, und dazwischen steigt es weich an. Über die Breite von zwei Pixeln wird der Rand so ein Verlauf: glatte Kanten mit einer Rechnung, für die PixelRAM in »Glatte Kanten« jeden Pixel an den Kanten mehrfach fragen musste. mix(a, b, t) mischt zwei Farben: t = 0 ergibt a, t = 1 ergibt b.
Die Vorschau ist 800 Pixel breit und 600 hoch. Welchen Wert hat p.x am
rechten Rand des Bildes, bei fragCoord.x = 800?
p.x = (2 · fragCoord.x − iResolution.x) / iResolution.y.
Früher oder später steht unten in der Vorschau eine rote Meldung. Der Grafikchip übersetzt deinen Shader beim Laden der Seite, und was er nicht versteht, meldet er mit der Zeile:

Hier steht in Zeile 10 2 / iResolution.y. 2 ist für GLSL eine ganze Zahl, ein int, und iResolution.y eine Kommazahl, ein float. Anders als C rechnet GLSL die beiden nicht von selbst um. Mit 2.0 ist der Fehler weg. Die häufigsten Fehler und wie man sie findet, stehen im Handbuch unter Fehler finden.
Mit einer Schleife werden aus einem Kreis acht, und mit iTime bewegen sie sich:
// Kreise in Bewegung, mit Farben aus einer Palette. // eine Farbe für jede Zahl t: drei Kosinuswellen, gegeneinander verschoben vec3 palette(float t) { return 0.5 + 0.5 * cos(6.28318 * (t + vec3(0.0, 0.33, 0.67))); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; float pixel = 2.0 / iResolution.y; vec3 color = vec3(0.04, 0.05, 0.1); for (int i = 0; i < 8; i++) { float k = float(i); // jeder Kreis auf seiner eigenen Bahn float a = iTime * 0.5 + k * 0.785; vec2 center = 0.65 * vec2(cos(a), sin(1.3 * a)); float d = length(p - center); vec3 c = palette(k / 8.0 + iTime * 0.05); // ein Schein um den Kreis, schwächer, je weiter weg color += c * 0.012 / d; // der Kreis selbst color = mix(color, c, 1.0 - smoothstep(0.15 - pixel, 0.15 + pixel, d)); } fragColor = vec4(color, 1.0); }

float(i) macht aus der ganzen Zahl i eine Kommazahl. Wieder rechnet GLSL das nicht von selbst um.cos(a) nach links und rechts, sin(1.3 * a) nach oben und unten, etwas schneller. So entsteht keine Kreisbahn, sondern eine Schleife, die sich langsam verschiebt.palette macht aus einer Zahl eine Farbe: Rot, Grün und Blau sind drei Kosinuswellen, um ein Drittel gegeneinander verschoben. Íñigo Quílez hat diese Paletten beschrieben; mit anderen Zahlen statt 0.0, 0.33, 0.67 entstehen ganz andere Farben.c * 0.012 / d: dicht am Kreis viel, weiter weg immer weniger. Weil der Schein aller acht Kreise addiert wird, leuchtet es heller, wo sie sich nahe kommen.Wo sich zwei Kreise nahe kommen, leuchtet der Hintergrund zwischen ihnen heller als um einen einzelnen Kreis. Warum?
0.012 einmal 0.05 und lass den Kreis selbst weg (die Zeile mit mix). Achte darauf, wie aus den Scheiben leuchtende Kugeln werden, die heller werden, wenn sie sich überlagern.
Ein Kaleidoskop spiegelt ein kleines Stück eines Bildes immer wieder um die Mitte. Dafür fragt der Shader nicht mehr nach x und y, sondern nach Abstand und Winkel, den Polarkoordinaten:
// Ein Kaleidoskop: Polarkoordinaten, gespiegelte Segmente und ein Muster // aus Kacheln mit fract. vec3 palette(float t) { return 0.5 + 0.5 * cos(6.28318 * (t + vec3(0.0, 0.33, 0.67))); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // Polarkoordinaten: wie weit von der Mitte, in welchem Winkel float r = length(p); float a = atan(p.y, p.x); // in 8 Segmente teilen und jedes zweite spiegeln, wie im Kaleidoskop float segment = 6.28318 / 8.0; a = mod(a, segment); a = abs(a - segment / 2.0); vec2 q = r * vec2(cos(a), sin(a)); // das Muster wandert langsam nach außen q.x -= iTime * 0.15; // Kacheln: fract nimmt nur den Nachkommateil, jede Kachel hat ihre Mitte bei 0 vec2 cell = fract(q * 3.0) - 0.5; vec2 id = floor(q * 3.0); float d = length(cell); vec3 color = palette(0.1 * id.x + 0.3 * r + iTime * 0.05); // Ringe in jeder Kachel color *= 0.6 + 0.4 * cos(30.0 * d - iTime * 2.0); // nach außen dunkler color *= smoothstep(1.6, 0.2, r); fragColor = vec4(color, 1.0); }

atan(p.y, p.x) ist der Winkel des Pixels um die Mitte, von −π bis π.mod(a, segment) macht aus jedem Winkel einen im ersten Achtel des Kreises. Alle acht Segmente zeigen so dasselbe.abs(a - segment / 2.0) spiegelt die eine Hälfte des Segments auf die andere. Darum stoßen benachbarte Segmente ohne Kante aneinander, wie die Spiegel im Kaleidoskop.q, und auf den legt fract ein Gitter aus Kacheln: fract(q * 3.0) ist die Position in der Kachel, floor(q * 3.0) ihre Nummer. Jede Kachel bekommt mit ihrer Nummer eine eigene Farbe und Ringe um ihre Mitte.6.0 oder 12.0 statt durch 8.0. Achte auf die Mitte: Aus wie vielen Blättern besteht der Stern jetzt?
Zufall gibt es auf dem Grafikchip nicht, jeder Pixel muss bei jedem Bild dasselbe ausrechnen. Wolken entstehen trotzdem, aus Rauschen: einer Zufallszahl für jeden Punkt eines Gitters, die immer dieselbe ist, und weichen Übergängen dazwischen.
Die Idee stammt von Ken Perlin. Nach der Arbeit an den Computerbildern für den Film Tron störte ihn, wie glatt und sauber alles aussah, was aus dem Computer kam, und 1985 beschrieb er das Rauschen, das heute seinen Namen trägt. Hier steht die einfachere Form, Value Noise: Auf den Gitterpunkten liegen Zahlen. Perlin legte dort Richtungen ab, das gibt ein gleichmäßigeres Rauschen.
Ken Perlin (geboren 1954), Informatiker in New York. Nach den Computerbildern für den Film Tron (1982) störte ihn, wie glatt und sauber Oberflächen aus dem Computer aussahen, und er erfand das Rauschen, das seinen Namen trägt. Dafür bekam er 1997 einen Oscar für technische Leistungen.
Wolken, Feuer und ganze Landschaften in Spielen wie Minecraft entstehen aus Perlin-Rauschen und seinen Verwandten.
Foto: Ambuj Saxena, 2007, CC BY 2.5, zugeschnitten (Wikimedia Commons)
An Image Synthesizer, Ken Perlin, SIGGRAPH 1985. Der Aufsatz mit dem Rauschen, aus dem Marmor, Feuer und Wolken werden, alles aus wenigen Zeilen: bei der ACM.
Wie das Rauschen genau geht, steht im Handbuch unter Prozedurale Texturen; hier ist das ganze Programm:
// Rauschen: Zufall, der weich ist. Daraus Wolken über einem Himmel. // eine Zufallszahl von 0 bis 1 für jeden Punkt eines Gitters, immer dieselbe float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); } // Value Noise: zwischen den Gitterpunkten weich überblenden float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); // weicher als geradlinig float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } // fbm: Rauschen in mehreren Größen übereinander, jede Lage halb so groß und halb so stark float fbm(vec2 p) { float sum = 0.0; float amplitude = 0.5; for (int i = 0; i < 6; i++) { sum += amplitude * noise(p); p *= 2.0; amplitude *= 0.5; } return sum; } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // der Himmel: unten hell, oben tiefblau vec3 sky = mix(vec3(0.85, 0.9, 1.0), vec3(0.2, 0.4, 0.8), 0.5 + 0.5 * p.y); // die Wolken ziehen mit der Zeit nach links float clouds = fbm(p * 1.5 + vec2(iTime * 0.1, 0.0)); clouds = smoothstep(0.45, 0.75, clouds); vec3 color = mix(sky, vec3(1.0), clouds); fragColor = vec4(color, 1.0); }

hash macht aus einem Gitterpunkt eine Zahl, die zufällig aussieht, aber für denselben Punkt immer gleich ist. Sie ist dieselbe wie im Handbuch, von Dave Hoskins. Viele Beispiele im Netz nehmen stattdessen fract(sin(…) * 43758.5); das gibt auf manchen Grafikchips Kästchen statt Wolken.noise liest die Zahlen der vier Gitterpunkte um p und mischt weich zwischen ihnen.fbm legt sechs Lagen Rauschen übereinander, jede doppelt so fein und halb so stark wie die vorige. Erst damit sehen die Ränder der Wolken ausgefranst aus.smoothstep(0.45, 0.75, clouds) macht daraus Wolken mit Lücken: Wo das Rauschen unter 0,45 bleibt, ist Himmel.fbm zählt sechs Lagen Rauschen zusammen, mit den Stärken 0.5, 0.25,
0.125 und so weiter. noise gibt höchstens 1 zurück. Welchen Wert
kann fbm höchstens erreichen?
0.5 + 0.25 + 0.125 + 0.0625 + 0.03125 + 0.015625.
fbm die Schleife nur zweimal laufen statt sechsmal. Achte auf die Ränder der Wolken. Was fehlt?
Jetzt wird es dreidimensional, mit derselben Idee wie im Raytracer: Für jeden Pixel geht ein Strahl vom Auge durch den Pixel in die Szene, und die Farbe des Pixels ist die Farbe dessen, was der Strahl trifft. Der Raytracer rechnete für jede Kugel mit einer Formel aus, wo der Strahl sie trifft. Das geht für Kugeln und Ebenen gut, für die meisten anderen Formen nicht.
Ein Shader fragt die Szene etwas Einfacheres: Wie weit ist es von diesem Punkt bis zur nächsten Fläche, egal in welcher Richtung? Die Antwort gibt eine Funktion map. Für eine Kugel ist das leicht, der Abstand zum Mittelpunkt weniger den Radius, für den Boden bei y = 0 ist es die Höhe y. Wenn bis zur nächsten Fläche noch d Platz ist, kann der Strahl d weit gehen, ohne etwas zu treffen. Dann fragt er wieder:

So tastet sich der Strahl vor: große Schritte im freien Raum, immer kleinere dicht an einer Fläche, bis der Abstand fast 0 ist. Das heißt Raymarching. Eine Formel für den Treffer braucht es nicht mehr, nur eine für den Abstand, und Abstände lassen sich kombinieren: Der Abstand zu zwei Dingen ist der kleinere der beiden Abstände.
Sphere Tracing: A Geometric Method for the Antialiased Ray Tracing of Implicit Surfaces, John C. Hart, The Visual Computer, 1996. Der Aufsatz beschreibt, wie ein Strahl mit einer Abstandsfunktion vorangeht, in Schritten, die nie in eine Fläche hineinreichen. Hart nennt das Sphere Tracing, nach den Kugeln um jeden Punkt, in denen nichts liegt, im Bild oben die Kreise: bei Springer.
Ersetze shader.frag durch dieses Programm:
// Raymarching: eine Kugel in einem dunklen Studio const vec3 LAMP = vec3(-0.32, 0.9, -0.3); // Richtung zur Lampe über der Szene, Länge 1 const vec3 LEFT = vec3(-0.88, 0.30, -0.37); // Richtung zum Leuchtstreifen links hinten, Länge 1 const vec3 RIGHT = vec3(0.86, 0.38, -0.34); // Richtung zum Leuchtstreifen rechts hinten, Länge 1 const vec3 DARK = vec3(0.003, 0.004, 0.008); // die Farbe des Studios, wo kein Licht ist // Abstand von p zur Kugel: zum Mittelpunkt, weniger den Radius float ball(vec3 p) { return length(p - vec3(0.0, 0.6, 0.0)) - 0.5; } // Abstand von p zur nächsten Oberfläche: die Kugel oder der Boden (y = 0) float map(vec3 p) { return min(ball(p), p.y); } // die Richtung, in der der Abstand am schnellsten wächst: die Normale vec3 normal(vec3 p) { vec2 e = vec2(0.001, 0.0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); } // das Studio: fast schwarz, links ein türkiser, rechts ein oranger Leuchtstreifen vec3 studio(vec3 dir) { vec3 color = DARK; color += vec3(0.1, 0.8, 1.0) * 4.0 * smoothstep(0.96, 0.985, dot(dir, LEFT)); color += vec3(1.0, 0.45, 0.1) * 4.0 * smoothstep(0.96, 0.985, dot(dir, RIGHT)); return color; } // das Licht der beiden Streifen auf einer Fläche, die in Richtung n zeigt vec3 strips(vec3 n) { return vec3(0.1, 0.8, 1.0) * 0.4 * max(dot(n, LEFT), 0.0) + vec3(1.0, 0.45, 0.1) * 0.4 * max(dot(n, RIGHT), 0.0); } // Raymarching: so weit gehen, wie der Abstand erlaubt; -1, wenn nichts kommt float march(vec3 from, vec3 dir) { float t = 0.0; for (int i = 0; i < 100; i++) { float d = map(from + dir * t); if (d < 0.001 * t) return t; t += d; if (t > 40.0) break; } return -1.0; } // die Farbe des Punktes q, den ein Strahl in Richtung dir trifft vec3 shade(vec3 q, vec3 dir) { vec3 n = normal(q); bool on_floor = ball(q) > q.y; vec3 albedo = on_floor ? vec3(0.04) : vec3(0.5); // die Lampe leuchtet einen Kreis auf dem Boden aus; // im Schatten, wenn ein Strahl zu ihr etwas trifft float lamp = max(dot(n, LAMP), 0.0) * smoothstep(2.6, 0.3, length(q.xz)); if (march(q + n * 0.002, LAMP) > 0.0) lamp = 0.0; return albedo * (vec3(1.0, 0.95, 0.9) * 2.0 * lamp + strips(n)); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // die Kamera bei eye schaut auf die Kugel vec3 eye = vec3(0.0, 1.4, -3.6); vec3 forward = normalize(vec3(0.0, 0.5, 0.0) - eye); vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward)); vec3 up = cross(forward, right); vec3 dir = normalize(p.x * right + p.y * up + 1.8 * forward); vec3 color = studio(dir); float t = march(eye, dir); if (t > 0.0) { color = shade(eye + dir * t, dir); // was weit weg ist, verschwindet im Dunkeln color = mix(color, DARK, smoothstep(6.0, 16.0, t)); } fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); }

Vieles kennst du aus dem Raytracer, nur steht es hier in GLSL, wo vec3 mit +, * und dot schon eingebaut ist. Die Welt sieht aber anders aus: kein Himmel und kein Schachbrett, sondern ein dunkles Studio, in dem nur Licht ist, wo eine Lampe hinleuchtet.
forward zeigt vom Auge zu dem Punkt, auf den die Kamera schaut. right steht quer dazu, waagrecht nach rechts: Das Kreuzprodukt cross zweier Richtungen steht auf beiden senkrecht. up steht auf forward und right senkrecht und zeigt nach oben. Der Strahl für den Pixel p geht p.x nach rechts, p.y nach oben und 1.8 nach vorn. Wer den Raytracer gebaut hat, kennt das aus »Vorne, rechts und oben«.march ist der Strahl aus dem Bild oben: Er fragt map und geht so weit, wie der Abstand erlaubt. Ist der Abstand fast 0, ist er angekommen, und t ist die Strecke bis dorthin. Wie »fast 0« ist, wächst mit t mit: Weit hinten muss ein Treffer nicht so genau sein, denn ein Pixel bedeckt dort viel mehr.normal braucht keine Formel für jede Form. Es fragt map an sechs Punkten dicht um den Treffer und sieht, in welche Richtung der Abstand am schnellsten wächst. Das ist die Richtung, in die die Fläche zeigt.smoothstep(2.6, 0.3, length(q.xz)) lässt ihr Licht zum Rand hin auslaufen, wie bei einem Scheinwerfer: Ein Kreis auf dem Boden wird hell. Links und rechts hinter der Kamera leuchten zwei farbige Streifen, die studio zeigt, wenn ein Strahl in ihre Richtung geht, und strips gibt ihr Licht auf die Flächen. Darum hat die Kugel einen türkisen und einen orangen Rand.pow(color, vec3(1.0 / 2.2)) am Ende hellt die dunklen Töne auf, so wie Bildschirme es erwarten. Darum steht für den Boden eine kleine Zahl wie 0.04, die am Ende heller aussieht, als sie klingt.Jeder Strahl beginnt bei eye = (0, 1.4, -3.6). Wie weit geht er beim
ersten Schritt in march? Die Kugel hat ihren Mittelpunkt bei
(0, 0.6, 0) und den Radius 0.5.
map(eye) = min(ball(eye), eye.y).
Mit einer Kugel ist der Shader nicht besser als der Raytracer. Raymarching kann aber, was der Raytracer nicht kann: Formen weich ineinanderfließen lassen. Dafür nimmt map nicht das kleinere von zwei Abständen, min, sondern ein weiches Minimum, smin. Wo zwei Formen weit auseinander sind, ist es dasselbe wie min. Wo beide fast gleich nah sind, wird es kleiner als beide, und zwischen den Formen wächst ein Hals, der sie verbindet.
Auf einer Linie durch zwei Tropfen sieht man, was smin tut:

In 2D, mit den Zahlen aus dem Shader, sieht das so aus: Sind sich zwei Tropfen nah genug, wölben sie sich einander entgegen, und kommen sie noch näher, wächst der Hals.

Kugeln, die so ineinanderfließen, heißen Metaballs. Erfunden hat sie Jim Blinn um 1980, für Bilder von Atomen in einer Fernsehserie. Bei ihm hat jede Kugel ein Feld, das mit dem Abstand schwächer wird, so wie das Licht einer Lampe. Die Felder aller Kugeln werden addiert, und die Oberfläche liegt dort, wo die Summe einen bestimmten Wert erreicht. Kommen sich zwei Kugeln nahe, ist die Summe zwischen ihnen groß, und die Oberfläche wölbt sich hinüber. Das weiche Minimum macht dasselbe mit Abständen, so wie Raymarching sie braucht.
Jim Blinn (geboren 1949), amerikanischer Informatiker. Am Jet Propulsion Laboratory der NASA machte er die Animationen der Vorbeiflüge der Voyager-Sonden und Bilder für Carl Sagans Fernsehserie Cosmos (1980). Um dort Atome zu zeigen, die sich zu Molekülen verbinden, erfand er Formen, die mit ihren Nachbarn verschmelzen: die Blobby Objects, heute Metaballs.
Auch der Glanzpunkt auf deinen Tropfen kommt von ihm: Die Richtung genau zwischen Licht und Auge ist seine Verbesserung des Phong-Modells von 1977. Und nach »Blinns Gesetz« dauert ein Bild immer gleich lange, egal wie schnell die Computer werden: Man rechnet einfach schönere Bilder.
Foto: Eric Haines, 2015, CC BY 2.0, zugeschnitten (Wikimedia Commons)
A Generalization of Algebraic Surface Drawing, James F. Blinn, ACM Transactions on Graphics, 1982. Der Aufsatz, in dem die Blobby Objects zum ersten Mal beschrieben sind, mit Bildern von Molekülen: bei der ACM.
Lösche die Funktion ball und schreib an ihre Stelle das weiche Minimum und sechs Tropfen:
const vec3 LEFT = vec3(-0.88, 0.30, -0.37); // Richtung zum Leuchtstreifen links hinten, Länge 1const vec3 RIGHT = vec3(0.86, 0.38, -0.34); // Richtung zum Leuchtstreifen rechts hinten, Länge 1const vec3 DARK = vec3(0.003, 0.004, 0.008); // die Farbe des Studios, wo kein Licht ist// glatte Vereinigung zweier Abstände: je größer k, desto weiter fließen sie ineinanderfloat smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h);}// wo die sechs Tropfen gerade sind; mainImage rechnet es einmal für jeden Pixel ausvec3 blob[6];void move_blobs(){ for (int i = 0; i < 6; i++) { float f = float(i); float t = iTime * (0.4 + 0.1 * f) + f * 1.7; blob[i] = vec3(1.3 * sin(t), 0.6 + 0.12 * sin(t * 1.3 + f), 0.9 * cos(t * 0.9 + f)); }}// die Farbe des Tropfens ivec3 blob_paint(int i){ if (i == 0) return vec3(0.9, 0.12, 0.005); if (i == 1) return vec3(0.0, 0.25, 0.32); if (i == 2) return vec3(0.6, 0.005, 0.12); if (i == 3) return vec3(0.9, 0.5, 0.005); if (i == 4) return vec3(0.05, 0.05, 0.5); return vec3(0.15, 0.45, 0.02);}// Abstand von p zu den Tropfenfloat blobs(vec3 p){ // weit weg genügt der Abstand zu einer Kugel, die alle Tropfen umschließt float bound = length(p - vec3(0.0, 0.6, 0.0)) - 2.2; if (bound > 0.5) return bound; float d = 100.0; for (int i = 0; i < 6; i++) d = smin(d, length(p - blob[i]) - 0.42, 0.6); return d;}// Abstand von p zur nächsten Oberfläche: die Tropfen oder der Boden (y = 0)float map(vec3 p){
In map stehen jetzt die Tropfen statt der Kugel:
float map(vec3 p){ return min(blobs(p), p.y);}
Jeder Tropfen hat seine eigene Farbe, und wo zwei ineinanderfließen, sollen auch die Farben ineinanderlaufen. Schreib unter map:
{ return min(blobs(p), p.y);}// die Farbe der Tropfen bei p: jeder Tropfen färbt umso mehr, je näher er istvec3 blob_color(vec3 p){ vec3 sum = vec3(0.0); float weight = 0.0; for (int i = 0; i < 6; i++) { float w = exp(-14.0 * (length(p - blob[i]) - 0.42)); sum += w * blob_paint(i); weight += w; } return sum / weight;}// die Richtung, in der der Abstand am schnellsten wächst: die Normalevec3 normal(vec3 p){
Die Schatten werden weich. Im Raytracer brauchte ein weicher Schatten viele Strahlen zu verschiedenen Punkten der Sonne, gemittelt über viele Durchgänge. Raymarching bekommt ihn fast umsonst: Der Strahl zur Lampe merkt sich, wie knapp er an etwas vorbeigekommen ist. Wer dicht an einem Tropfen vorbeiläuft, liegt im Halbschatten. Schreib unter normal:
map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx)));}// weicher Schatten: wie knapp läuft der Strahl zur Lampe an etwas vorbei?float shadow(vec3 p){ float light = 1.0; float t = 0.02; for (int i = 0; i < 48; i++) { float d = map(p + LAMP * t); light = min(light, 10.0 * d / t); t += clamp(d, 0.02, 0.2); // über allen Tropfen kann nichts mehr Schatten werfen if (light < 0.001 || p.y + LAMP.y * t > 1.5) break; } return clamp(light, 0.0, 1.0);}// das Studio: fast schwarz, links ein türkiser, rechts ein oranger Leuchtstreifenvec3 studio(vec3 dir){
shade fragt jetzt die Tropfen und den weichen Schatten:
// die Farbe des Punktes q, den ein Strahl in Richtung dir trifft vec3 shade(vec3 q, vec3 dir) { vec3 n = normal(q); bool on_floor = blobs(q) > q.y; vec3 albedo = on_floor ? vec3(0.04) : blob_color(q); // die Lampe leuchtet einen Kreis auf dem Boden aus, mit weichen Schatten float lamp = max(dot(n, LAMP), 0.0) * smoothstep(3.0, 0.3, length(q.xz)) * shadow(q + n * 0.002); return albedo * (vec3(1.0, 0.95, 0.9) * 2.0 * lamp + strips(n)); }
Zuletzt rechnet mainImage die Tropfen an ihren Platz, und die Kamera schwenkt:
void mainImage(out vec4 fragColor, in vec2 fragCoord){ vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; move_blobs(); // die Kamera schwenkt vor den Tropfen hin und her // und schaut auf ihre Mitte float angle = 0.7 * sin(0.2 * iTime); vec3 eye = vec3(3.6 * sin(angle), 1.4, -3.6 * cos(angle)); vec3 forward = normalize(vec3(0.0, 0.6, 0.0) - eye); vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward)); vec3 up = cross(forward, right);

smin mischt die beiden Abstände, wo sie weniger als k auseinanderliegen, und zieht dabei etwas ab. Darum wird der Abstand zwischen zwei nahen Tropfen kleiner, als jeder allein wäre: Dort ist schon Fläche, und es entsteht der Hals. Wie man darauf kommt, erklärt Íñigo Quílez in seinem Artikel über das weiche Minimum.blob ist ein Array mit den sechs Mittelpunkten. Sie hängen nur von der Zeit ab, also rechnet move_blobs sie einmal für jeden Pixel aus, statt bei jeder Frage an map wieder mit sin und cos. map wird für einen Pixel bis zu einige hundert Mal gefragt.bound spart noch mehr: Ist ein Punkt weit weg von allen Tropfen, reicht der Abstand zu einer großen Kugel um alle sechs. Er ist nie größer als der Abstand zu den Tropfen selbst, der Strahl springt also nie zu weit, und es müssen nicht sechs Tropfen gefragt werden.blob_color gewichtet die Farben der Tropfen: Ein Tropfen, dessen Oberfläche 0,1 weiter weg ist, zählt nur noch ein Viertel so viel, exp(-14 · 0.1).shadow geht wie march zur Lampe, aber merkt sich in light das kleinste Verhältnis von Abstand zu Strecke. Läuft der Strahl knapp an einem Tropfen vorbei, ist es klein, und der Punkt liegt im Halbschatten. Die 10 bestimmt, wie weich: Mit 30 werden die Schatten härter. Mehr dazu bei Íñigo Quílez über weiche Schatten.Ein Punkt liegt genau zwischen zwei Tropfen: Bis zum einen ist es 0.3,
bis zum anderen auch. Was gibt smin(0.3, 0.3, 0.6) zurück?
Rechne erst h = clamp(0.5 + 0.5 · (b − a) / k, 0, 1), dann mix(b, a, h) − k · h · (1 − h).
blobs die 0.6 in smin auf 0.1 und dann auf 1.2. Achte darauf, wann zwei Tropfen anfangen, einander entgegenzufließen, und wie dick der Hals zwischen ihnen wird.
Drei Dinge fehlen noch: Glanz auf den Tropfen, ein Boden, in dem sie sich spiegeln, und dunklere Stellen, wo wenig Licht hinkommt, unter den Tropfen und in den Kehlen zwischen ihnen. Für das Letzte fragt der Shader map ein paar Mal entlang der Normale: Ist über der Fläche weniger Platz, als der Weg nach außen lang ist, liegt dort etwas, das Licht wegnimmt. Das heißt Umgebungsverdeckung (englisch Ambient Occlusion). Schreib unter shadow:
} return clamp(light, 0.0, 1.0);}// Umgebungsverdeckung: wie viel Platz ist über der Oberfläche?float occlusion(vec3 p, vec3 n){ float hidden = 0.0; for (int i = 1; i <= 5; i++) { float h = 0.03 + 0.1 * float(i); hidden += h - map(p + n * h); } return clamp(1.0 - 1.2 * hidden, 0.0, 1.0);}// das Studio: fast schwarz, links ein türkiser, rechts ein oranger Leuchtstreifenvec3 studio(vec3 dir){
Dann wird shade noch einmal länger:
// die Farbe des Punktes q, den ein Strahl in Richtung dir trifft vec3 shade(vec3 q, vec3 dir) { vec3 n = normal(q); bool on_floor = blobs(q) > q.y; vec3 albedo = on_floor ? vec3(0.04) : blob_color(q); // die Lampe leuchtet einen Kreis auf dem Boden aus, mit weichen Schatten float lamp = max(dot(n, LAMP), 0.0) * smoothstep(3.0, 0.3, length(q.xz)) * shadow(q + n * 0.002); float ao = occlusion(q, n); vec3 color = albedo * (vec3(1.0, 0.95, 0.9) * 2.0 * lamp + strips(n) * ao); if (!on_floor) { // Glanz: die Lampe als Lichtpunkt, das Studio gespiegelt, am Rand mehr vec3 h = normalize(LAMP - dir); float fresnel = 0.04 + 0.6 * pow(1.0 - max(dot(n, -dir), 0.0), 5.0); color += vec3(1.0, 0.95, 0.9) * pow(max(dot(n, h), 0.0), 120.0) * 2.0 * lamp; color = mix(color, studio(reflect(dir, n)) * ao, fresnel); } return color; }
Der Boden spiegelt mit einem zweiten Strahl, so wie die Spiegelkugel im Raytracer. In mainImage:
vec3 color = studio(dir); float t = march(eye, dir); if (t > 0.0) { vec3 q = eye + dir * t; color = shade(q, dir); // der Boden ist schwarz poliert: ein zweiter Strahl, gespiegelt if (blobs(q) > q.y) { vec3 mirrored = reflect(dir, vec3(0.0, 1.0, 0.0)); vec3 from = q + vec3(0.0, 0.002, 0.0); float s = march(from, mirrored); vec3 seen = s > 0.0 ? shade(from + mirrored * s, mirrored) : DARK; color = mix(color, seen, 0.5); } // was weit weg ist, verschwindet im Dunkeln color = mix(color, DARK, smoothstep(6.0, 16.0, t)); }

Vergleiche mit dem Bild davor, es ist derselbe Augenblick:
h, und die hohe Potenz 120.0 macht ihn klein und scharf. Diese Rechnung mit der Richtung dazwischen stammt von Jim Blinn, 1977.march und shade gibt es schon.occlusion fragt map in fünf Abständen über der Fläche. Unter einem Tropfen ist dort weniger Platz als der Weg nach oben, und das Licht der Streifen wird schwächer.float s = march(from, mirrored); zwei Schrägstriche und darunter float s = -1.0;. Dann spiegelt der Boden nichts mehr. Achte auf die Bilder pro Sekunde oben rechts, mit und ohne. Zeigt dein Rechner beide Male so viel, wie der Bildschirm kann, zieh das Vorschaufenster so groß wie möglich. Nimm die Änderung danach wieder zurück.
Zum Schluss steuerst du die Kamera selbst. Solange die Maus gedrückt ist, ist iMouse.z größer als 0, und iMouse.xy ist ihre Position in Pixeln. Ersetze in mainImage die Zeilen für angle und eye:
move_blobs(); // die Kamera schwenkt vor den Tropfen hin und her; mit gedrückter Maus // bestimmt die Maus, von wo sie schaut: links und rechts einmal ganz herum, // oben und unten die Höhe float angle = 0.7 * sin(0.2 * iTime); float height = 1.4; if (iMouse.z > 0.0) { angle = 6.28318 * iMouse.x / iResolution.x; height = 0.3 + 4.0 * iMouse.y / iResolution.y; } vec3 eye = vec3(3.6 * sin(angle), height, -3.6 * cos(angle)); vec3 forward = normalize(vec3(0.0, 0.6, 0.0) - eye);

Zieh mit gedrückter Maus nach links und rechts, dann fliegt die Kamera um die Tropfen herum, nach oben und unten steigt und sinkt sie. Lässt du los, schwenkt sie wieder von selbst.
Raymarching kann mehr, als Formen verschmelzen. Es kann eine Form ohne Ende wiederholen, und jede Wiederholung noch einmal in klein. Bevor es dreidimensional wird, erst einmal in der Ebene: Nimm ein Quadrat, teile es in drei mal drei Kacheln und mach die mittlere zu einem Loch. Mit jeder der acht anderen Kacheln machst du dasselbe, mit deren Kacheln wieder, ohne Ende. Das ist der Sierpinski-Teppich, 1916 von Wacław Sierpiński beschrieben.
Wacław Sierpiński (1882–1969), polnischer Mathematiker in Warschau, mit über 700 Aufsätzen und 50 Büchern. 1916 beschrieb er ein Quadrat, aus dem die Mitte fehlt, und aus jedem der acht Quadrate drumherum wieder die Mitte, ohne Ende: den Sierpinski-Teppich. Auch ein Dreieck aus Dreiecken trägt seinen Namen.
Ein Muster, das in jedem Ausschnitt wieder sich selbst enthält, heißt heute Fraktal. Dein Shader rechnet den Teppich mit einer Schleife aus vier Zeilen.
Foto: unbekannt, 1928, gemeinfrei, zugeschnitten (Wikimedia Commons)
Ersetze shader.frag durch dieses Programm:
// Der Sierpinski-Teppich: ein Quadrat, aus dem die Mitte fehlt, und aus jedem // der acht Quadrate drumherum wieder die Mitte, immer weiter // 1 auf dem Teppich, 0 in einem Loch; p läuft im Quadrat von 0 bis 1 float carpet(vec2 p, int steps) { for (int i = 0; i < 6; i++) { if (i >= steps) break; // in drei mal drei Kacheln teilen: welche Kachel, und wo darin? p *= 3.0; vec2 cell = floor(p); p = fract(p); // die mittlere Kachel ist ein Loch if (cell.x == 1.0 && cell.y == 1.0) return 0.0; } return 1.0; } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // jede Sekunde eine Stufe mehr: 1, 2, 3, 4, 5, dann von vorn int steps = 1 + int(mod(iTime, 5.0)); // das Quadrat von -0.9 bis 0.9 auf 0 bis 1 bringen vec2 q = (p + 0.9) / 1.8; vec3 color = vec3(0.01, 0.012, 0.018); if (q.x > 0.0 && q.x < 1.0 && q.y > 0.0 && q.y < 1.0) { vec3 stone = vec3(0.45, 0.5, 0.55) * (0.85 + 0.15 * q.y); vec3 hole = vec3(0.02, 0.03, 0.04); color = mix(hole, stone, carpet(q, steps)); } fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); }
Jede Sekunde kommt eine Stufe dazu, nach fünf beginnt es von vorn:
p *= 3.0 macht aus dem Quadrat von 0 bis 1 eines von 0 bis 3. floor(p) ist dann die Nummer der Kachel, 0, 1 oder 2 in jeder Richtung, und fract(p) die Lage in ihr, wieder von 0 bis 1. Das kennst du vom Kaleidoskop.(1, 1). Liegt der Pixel darin, ist er in einem Loch, und die Schleife ist fertig.if (i >= steps) break; hält die Schleife nach steps Runden an. Die Grenze in for muss in GLSL eine feste Zahl sein, darum steht dort 6 und die eigentliche Grenze innen.Teile das Quadrat in drei mal drei Kacheln und nimm die mittlere weg, dann dasselbe mit jeder übrigen Kachel, und noch einmal. Nach diesen drei Stufen ist das Quadrat in 27 mal 27 kleinste Kacheln geteilt. Wie viele davon gehören noch zum Teppich?
Nach einer Stufe bleiben 8 Kacheln. Jede davon wird wieder in 9 geteilt, und 8 bleiben.
Dasselbe geht mit einem Würfel. Teile ihn in drei mal drei mal drei kleinere Würfel und nimm sieben davon weg: den in der Mitte und die sechs in der Mitte jeder Seite. Zusammen sind sie ein Kreuz aus drei Balken, die quer durch den Würfel gehen. Jeder der übrigen Würfel bekommt dasselbe Kreuz, und so weiter. Das ist der Menger-Schwamm, 1926 von Karl Menger beschrieben.
Karl Menger (1902–1985), österreichischer Mathematiker, ab 1937 in den USA. 1926 fragte er, was die Dimension einer Form eigentlich ist, und beschrieb dafür einen Würfel, aus dem in jeder Stufe Kreuze fehlen: den Menger-Schwamm, Sierpińskis Teppich in drei Dimensionen.
Mit jeder Stufe schrumpft sein Volumen gegen null, und seine Oberfläche wächst ohne Grenze. Mathematisch ist er darum weder ein Körper noch eine Fläche, sondern eine Kurve.
Foto: Shimer College, 1970, gemeinfrei, zugeschnitten (Wikimedia Commons)
Ersetze shader.frag durch dieses Programm:
// Der Menger-Schwamm: ein Würfel, aus dem in jeder Stufe ein Kreuz aus drei Balken // fehlt, und aus jedem der 20 Würfel drumherum wieder, immer kleiner // Abstand von p zu einem Würfel mit der halben Kantenlänge 1 um den Nullpunkt float box(vec3 p) { vec3 q = abs(p) - 1.0; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0); } // Abstand zu drei unendlich langen Balken entlang x, y und z durch den Nullpunkt, // jeder w breit nach jeder Seite float bars(vec3 p, float w) { vec3 a = abs(p); return min(max(a.y, a.z), min(max(a.x, a.z), max(a.x, a.y))) - w; } // der Schwamm: der Würfel, weniger die Balken jeder Stufe float sponge(vec3 p) { float d = box(p); float size = 2.0; // die Kantenlänge der Würfel dieser Stufe for (int i = 0; i < 4; i++) { // die Lage im Würfel dieser Stufe, seine Mitte bei 0: mod wiederholt ihn vec3 q = mod(p + size / 2.0, size) - size / 2.0; // durch seine Mitte ein Kreuz ein Drittel so breit wie er wegnehmen d = max(d, -bars(q, size / 6.0)); size /= 3.0; } return d; } mat2 rotate(float a) { return mat2(cos(a), sin(a), -sin(a), cos(a)); } // der Schwamm dreht sich langsam und steht schräg vec3 turn(vec3 p) { p.xz = rotate(0.25 * iTime) * p.xz; p.yz = rotate(0.6) * p.yz; p.xy = rotate(0.5) * p.xy; return p; } float map(vec3 p) { return sponge(turn(p)); } vec3 normal(vec3 p) { vec2 e = vec2(0.0005, 0.0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); } // Umgebungsverdeckung: tief in den Löchern kommt wenig Licht an float occlusion(vec3 p, vec3 n) { float hidden = 0.0; for (int i = 1; i <= 5; i++) { float h = 0.01 + 0.04 * float(i); hidden += (h - map(p + n * h)) / float(i); } return clamp(1.0 - 3.0 * hidden, 0.0, 1.0); } // weicher Schatten in Richtung l, höchstens bis zur Entfernung far float shadow(vec3 p, vec3 l, float far) { float light = 1.0; float t = 0.01; for (int i = 0; i < 32; i++) { float d = map(p + l * t); light = min(light, 16.0 * d / t); t += clamp(d, 0.01, 0.2); if (light < 0.001 || t > far) break; } return clamp(light, 0.0, 1.0); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // von leicht unten: der Würfel ragt über der Kamera auf vec3 eye = vec3(0.0, -1.0, -3.9); vec3 forward = normalize(vec3(0.0, 0.05, 0.0) - eye); vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward)); vec3 up = cross(forward, right); vec3 dir = normalize(p.x * right + p.y * up + 1.7 * forward); float t = 0.0; bool hit = false; float haze = 0.0; for (int i = 0; i < 110; i++) { float d = map(eye + dir * t); // Dunst: je knapper der Strahl am Würfel vorbeiläuft, desto mehr leuchtet die Luft haze += 0.004 / (1.0 + 80.0 * d * d); if (d < 0.0005 * t) { hit = true; break; } t += d; if (t > 8.0) break; } // fast schwarz, oben ein Hauch von kaltem Licht vec3 color = vec3(0.003, 0.004, 0.007) + vec3(0.006, 0.012, 0.02) * smoothstep(-0.2, 1.0, p.y); if (hit) { vec3 q = eye + dir * t; vec3 n = normal(q); float ao = occlusion(q, n); // dunkler Stein, ein kaltes Licht von oben, ein schwacher Rand vec3 stone = vec3(0.12, 0.13, 0.15); vec3 top = normalize(vec3(-0.3, 1.0, -0.2)); float light = max(dot(n, top), 0.0) * shadow(q + n * 0.001, top, 3.0); color = stone * vec3(0.55, 0.75, 1.0) * 2.0 * light * ao; color += stone * vec3(0.05, 0.07, 0.1) * ao; float rim = pow(1.0 - max(dot(n, -dir), 0.0), 5.0); color += vec3(0.3, 0.55, 0.8) * rim * 0.25 * ao; // aus dem Kern dringt eisblaues Licht: je näher an der Mitte, desto heller vec3 inside = turn(q); float core = exp(-6.0 * dot(inside, inside)); color += vec3(0.25, 0.8, 1.0) * 4.5 * core * (0.3 + 0.7 * max(dot(n, -normalize(inside)), 0.0)); } color += vec3(0.2, 0.45, 0.6) * haze * 0.25; // zum Rand dunkler, und sanft begrenzen statt abschneiden color *= 1.0 - 0.4 * dot(p * 0.6, p * 0.6); color = 1.0 - exp(-color * 1.3); fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); }

box ist der Abstand zu einem Würfel, so wie length(p) - r der zu einer Kugel ist. Die Formeln für viele andere Formen hat Íñigo Quílez gesammelt.bars ist der Abstand zu dem Kreuz aus drei Balken. Ein Balken entlang x ist alles, was in y und z nahe an der Mitte liegt: max(a.y, a.z) - w.max(d, -bars(q, …)) nimmt das Kreuz aus dem Würfel heraus. min vereinigt zwei Formen, max mit einem Minus davor zieht eine Form von der anderen ab.mod(p + size / 2.0, size) - size / 2.0 ist dasselbe wie fract im Teppich: Es macht aus jedem Punkt die Lage im kleinen Würfel dieser Stufe. So bekommt jeder Würfel sein Kreuz, ohne dass das Programm sie einzeln kennt. Mit jeder Runde wird size dreimal kleiner.turn dreht den Schwamm mit Drehmatrizen, rotate, langsam um die senkrechte Achse und schräg gestellt.haze sammelt beim Marschieren, wie knapp der Strahl am Würfel vorbeikommt, und lässt die Luft um ihn herum leuchten. Das kostet nichts extra: Die Abstände sind ohnehin da.Ein Würfel wird in 27 kleinere geteilt, und das Kreuz aus 7 Würfeln fällt weg. Mit jedem der übrigen geschieht dasselbe. Aus wie vielen kleinsten Würfeln besteht der Schwamm nach zwei Stufen?
Nach einer Stufe bleiben 27 − 7 = 20 Würfel.
sponge nur einmal laufen, dann zweimal, dann dreimal (i < 1, i < 2, …). Achte darauf, ab welcher Stufe du die feinsten Löcher in der Vorschau gar nicht mehr siehst.
Was passiert, wenn der Würfel fehlt? Ohne box ist überall Stein, und die Balken jeder Stufe fressen Gänge hinein, in alle Richtungen und ohne Ende. Wo sich drei Gänge kreuzen, entsteht ein Raum, und die kleineren Löcher werden zu einem Muster aus Platten an seinen Wänden. Ersetze shader.frag durch dieses Programm:
// Im Menger-Schwamm: ohne den äußeren Würfel füllt der Schwamm den ganzen Raum. // Aus den Löchern werden Gänge, wo sie sich kreuzen, Räume // Abstand zu drei unendlich langen Balken entlang x, y und z durch den Nullpunkt, // jeder w breit nach jeder Seite float bars(vec3 p, float w) { vec3 a = abs(p); return min(max(a.y, a.z), min(max(a.x, a.z), max(a.x, a.y))) - w; } // der Schwamm ohne Würfel: überall Stein, weniger die Balken jeder Stufe float sponge(vec3 p) { float d = -100.0; float size = 6.0; // die Kantenlänge der Würfel dieser Stufe: ein Raum alle 6 Einheiten for (int i = 0; i < 4; i++) { // die Lage im Würfel dieser Stufe, seine Mitte bei 0: mod wiederholt ihn vec3 q = mod(p + size / 2.0, size) - size / 2.0; // durch seine Mitte ein Kreuz ein Drittel so breit wie er wegnehmen d = max(d, -bars(q, size / 6.0)); size /= 3.0; } return d; } mat2 rotate(float a) { return mat2(cos(a), sin(a), -sin(a), cos(a)); } float map(vec3 p) { return sponge(p); } vec3 normal(vec3 p) { vec2 e = vec2(0.0005, 0.0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); } // Umgebungsverdeckung: tief in den Löchern kommt wenig Licht an float occlusion(vec3 p, vec3 n) { float hidden = 0.0; for (int i = 1; i <= 5; i++) { float h = 0.04 + 0.12 * float(i); hidden += (h - map(p + n * h)) / float(i); } return clamp(1.0 - 1.5 * hidden, 0.0, 1.0); } // eine Zufallszahl von 0 bis 1 für jeden Punkt, immer dieselbe float hash(vec3 p) { p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); } // das Licht eines Raums mit der Mitte c auf einer Fläche bei q mit der Normale n: // nur in manchen Räumen brennt eines, kalt, ganz selten warm vec3 room(vec3 c, vec3 q, vec3 n) { float r = hash(c * 0.37 + 11.0); if (r < 0.7) return vec3(0.0); vec3 tint = r < 0.95 ? vec3(0.55, 0.85, 1.0) : vec3(1.0, 0.6, 0.25); vec3 to = c - q; float d2 = dot(to, to); return tint * 8.0 * max(dot(n, to / sqrt(d2)), 0.0) / (1.0 + 0.6 * d2 * d2 / 9.0); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; // die Kamera ist gekippt, um gut 15 Grad, und schwankt langsam float roll = 0.27 + 0.05 * sin(iTime * 0.21); p = rotate(roll) * p; // langsam durch einen Gang, von Raum zu Raum vec3 eye = vec3(0.15 * sin(iTime * 0.3), 0.1 * cos(iTime * 0.23), iTime * 0.6); vec3 dir = normalize(vec3(p, 1.3)); dir.xz = rotate(0.12 * sin(iTime * 0.17)) * dir.xz; float t = 0.0; bool hit = false; for (int i = 0; i < 120; i++) { float d = map(eye + dir * t); if (d < 0.0005 * t) { hit = true; break; } t += d; if (t > 30.0) break; } vec3 color = vec3(0.0); if (hit) { vec3 q = eye + dir * t; vec3 n = normal(q); float ao = occlusion(q, n); // das Licht des eigenen Raums und der sechs Nachbarn, durch die Gänge vec3 home = floor(q / 6.0 + 0.5) * 6.0; vec3 light = room(home, q, n); light += room(home + vec3(6.0, 0.0, 0.0), q, n) + room(home - vec3(6.0, 0.0, 0.0), q, n); light += room(home + vec3(0.0, 6.0, 0.0), q, n) + room(home - vec3(0.0, 6.0, 0.0), q, n); light += room(home + vec3(0.0, 0.0, 6.0), q, n) + room(home - vec3(0.0, 0.0, 6.0), q, n); color = (vec3(0.004, 0.006, 0.008) + light) * 0.6 * ao * ao; // was weit weg ist, verschwindet im Dunkeln color *= exp(-0.12 * t); } color *= 1.0 - 0.3 * dot(p * 0.6, p * 0.6); color = 1.0 - exp(-color * 1.2); fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0); }

d = -100.0 am Anfang von sponge heißt: überall Stein. Es bleibt nur, was die Balken wegnehmen. Die ersten Balken stehen alle 6 Einheiten, so groß ist ein Raum.iTime die z-Achse entlang, genau in der Mitte eines Gangs, und schwankt dabei ein wenig.p = rotate(roll) * p kippt das ganze Bild um gut 15 Grad, bevor der Strahl losgeht. Mit schiefem Horizont wirkt ein Bild unheimlich, auch wenn sonst nichts geschieht.room gibt jedem Raum mit hash eine Zufallszahl, die für ihn immer gleich bleibt. Nur wo sie über 0,7 liegt, brennt ein Licht, kalt, ganz selten warm. Jeder Punkt fragt seinen eigenen Raum und die sechs Nachbarn, durch deren Gänge ihr Licht hereinfällt; mit der Entfernung wird es schnell schwächer, und der Rest bleibt dunkel.roll auf 0 und sieh dir den Flug noch einmal an. Ändere dann in room die 0.7 auf 0.3: Achte darauf, ob der Schwamm mit mehr Licht noch genauso wirkt.
Der Raytracer brauchte für ein glattes Bild viele Durchgänge. Dein Shader rechnet ein ganzes Bild, so oft der Bildschirm es zeigen kann, auf einem Grafikchip, der mit im Prozessor sitzt. Wie viel Arbeit das ist, kann man abschätzen: Für jeden Pixel geht ein Strahl mit bis zu 100 Schritten, dazu ein Strahl zur Lampe, die Normale, die Umgebungsverdeckung und auf dem Boden noch ein gespiegelter Strahl. map wird so für einen Pixel bis zu einige hundert Mal gefragt, und ein Fenster so groß wie ein Full-HD-Bildschirm hat zwei Millionen Pixel.
Die Anzeige oben rechts zeigt, was dein Rechner schafft. Schafft er weniger als 40 Bilder in der Sekunde, rechnet die Seite weniger Pixel, bis zur halben Breite und Höhe, und streckt das Bild aufs Fenster. Es wird etwas weicher, bleibt aber flüssig. Mit index.html?scale=0.5 in der Adresszeile legst du das selbst fest, mit ?scale=1 rechnet sie immer jeden Pixel.
Was du selbst tun kannst, wenn ein Shader ruckelt:
move_blobs die Mittelpunkte. Weit weg genügt eine grobe Antwort, so wie bound.march und 48 für den Schatten reichen hier. Wo der Strahl am Horizont den Boden streift, braucht er die meisten: Der Boden ist dort immer nah, und die Schritte bleiben klein.Jetzt bist du dran. Ein paar Ideen:
min für harte und smin für weiche Übergänge.fbm aus den Wolken und misch damit die Farbe des Steins im Schwamm: Die Wände bekommen Flecken, als wären sie alt.Gib deinen eigenen Shader ab: eine Welt, ein Muster oder etwas ganz
anderes. Wähle die Datei aus glsl-starter. Du kannst jederzeit einen
neuen abgeben oder deine Abgabe löschen.
Dein Shader hat für jeden Pixel des Bildes einen Strahl losgeschickt, viele Male in der Sekunde. Grafikchips sind dafür eigentlich nicht gebaut: Sie zeichnen Dreiecke, und ein Fragment-Shader rechnet die Farbe der Pixel eines Dreiecks aus. Dein index.html zeichnet genau zwei Dreiecke, die zusammen das Fenster bedecken, und lässt den Rest von deinem Shader rechnen. Íñigo Quílez hat diesen Trick 2008 in seinem Vortrag Rendering Worlds with Two Triangles beschrieben.
mainImage hat, laufen viele davon auch in deinem shader.frag: alle, die nur iResolution, iTime und iMouse verwenden und keine Bilder, Töne oder weiteren Puffer.