Auf Shadertoy gibt es Szenen mit Schatten, Spiegelungen und Nebel, und im Quelltext steht kein einziges Dreieck. Ich wollte wissen, wie das geht. Man braucht eine Funktion, die für jeden Punkt im Raum sagt, wie weit die nächste Oberfläche entfernt ist. Alles andere fällt daraus fast von allein heraus.
Ziehen dreht die Kamera, das Mausrad zoomt. Auf dem Boden schweben eine Kugel, eine abgerundete Box und ein Torus und verschmelzen, wenn sie sich begegnen. Der Chip steps zeigt statt des Bildes die Anzahl der Schritte pro Pixel, blend k stellt ein, wie weich die Formen ineinander laufen, und max steps ist das Schrittbudget.
Die ganze Idee in einer Schleife
Pro Pixel geht ein Strahl von der Kamera los. An seinem aktuellen Punkt fragt der Shader die Szene nach dem Abstand zur nächsten Oberfläche und läuft genau so weit vor. Dann fragt er wieder.
float t = 0.0;
for (int i = 0; i < uMaxSteps; i++) {
float d = map(ro + rd * t);
if (d < HIT_EPS + HIT_EPS_SLOPE * t) return t; // Treffer
t += d;
if (t > TMAX) return -1.0; // zu weit weg
}
return -1.0; // Budget aufgebraucht
Das ist sicher, weil der Abstand der Radius der größten Kugel um den Punkt ist, die garantiert leer bleibt. Ein Schritt von genau dieser Länge kann nichts überspringen. Im Test sieht man das gut: Eine Kugel mit Radius 1 steht bei z = 5, der Strahl startet im Ursprung. Der erste Aufruf liefert 4, der Strahl steht an der Oberfläche, der zweite liefert 0. Zwei Aufrufe, fertig. Läuft derselbe Strahl bei x = 0,995 knapp innerhalb der Silhouette vorbei, braucht er 28 Schritte, weil jeder Abstand winzig ist.
Formen sind Formeln
Eine Kugel ist die Länge des Vektors minus Radius. Der Torus ist kaum mehr: erst der Abstand zum Ring in der xz-Ebene, dann der zur Röhre.
inline float sdSphere(Vec3 p, float radius) { return length(p) - radius; }
inline float sdTorus(Vec3 p, float ring, float tube) {
const float qx = std::sqrt(p.x * p.x + p.z * p.z) - ring;
return std::sqrt(qx * qx + p.y * p.y) - tube;
}
Innen sind die Werte negativ. Der Test prüft das an Zahlen, die ich von Hand ausrechnen kann: Kugel mit Radius 1 bei Abstand 3 gibt 2, im Mittelpunkt -1. Bei der Box kommt es darauf an, ob der nächste Punkt auf einer Fläche, einer Kante oder einer Ecke liegt, dort kommen 2, die Wurzel aus 2 und die Wurzel aus 3 heraus.
Das Verschmelzen macht eine Funktion, die statt min(a, b) einen weichen Übergang liefert:
inline float smin(float a, float b, float k) {
if (k <= 0.0F) return std::fmin(a, b);
const float h = std::fmax(k - std::fabs(a - b), 0.0F) / k;
return std::fmin(a, b) - h * h * k * 0.25F;
}
Sind die beiden Werte weiter als k auseinander, kommt das normale Minimum heraus. Sonst sinkt das Ergebnis um bis zu k / 4 darunter, das ergibt die Wulst, in der sich Kugel und Torus treffen. Der Test prüft genau diese Grenzen auf einem Raster, und smin(1, 1, 1) muss 0,75 sein. Mit blend k auf 0,0 ist es wieder das harte Minimum.
Heatmap
Der Chip steps färbt jedes Pixel nach seiner Schrittzahl, von Papier über Teal zu Orange bei 48. Dunkel heißt: Das Budget war leer, bevor der Strahl irgendwo angekommen ist.
Mitten auf einer Fläche sind es ein paar Schritte. An den Silhouetten wird es orange, weil der Strahl dort an der Oberfläche entlangkriecht. Der Boden Richtung Horizont wird zu einem orangen Band, aus demselben Grund. Mit 64 Schritten bleibt am Horizont eine dünne dunkle Linie, mit 128 verschwindet sie, mit 16 bricht der Boden als Kreis weg und der Rest wird zum Himmel.
Schatten und Okklusion gibt es gratis
Die Normale ist der Gradient des Feldes. Der Shader nimmt vier Abfragen auf den Ecken eines Tetraeders und kommt damit mit weniger Aufrufen aus als mit sechs Abfragen entlang der Achsen. Dasselbe Feld liefert den Schatten: Vom Treffpunkt aus läuft ein zweiter Strahl zum Licht, und unterwegs merkt sich der Shader das kleinste Verhältnis aus Abstand zum Feld und zurückgelegter Strecke.
float res = 1.0;
float t = 0.02;
for (int i = 0; i < 40; i++) {
float d = map(ro + rd * t);
res = min(res, 8.0 * d / t);
t += clamp(d, 0.02, 0.5);
if (res < 0.001 || t > 12.0) break;
}
Ein Strahl, der knapp an einer Kugel vorbeigeht, bekommt damit einen Wert zwischen 0 und 1, und aus dem Wert wird der weiche Rand. Die 8 steuert die Härte. Für die Okklusion fragt der Shader fünf Punkte entlang der Normale ab:
for (int i = 0; i < 5; i++) {
float h = 0.01 + 0.12 * float(i) / 4.0;
occ += (h - map(p + n * h)) * weight;
weight *= 0.95;
}
Im Freien ist der Abstand an jedem dieser Punkte genau so groß wie die Höhe h, die Differenz ist 0. Steht etwas daneben, ist der Abstand kleiner, und der Punkt wird dunkler. Teuer wird es trotzdem: Pro Pixel sind es bis zu 64 Schritte, dazu bis zu 40 für den Schatten, 4 für die Normale und 5 für die Okklusion, jeweils mit drei Distanzfunktionen.
Stolperfallen
Eine feste Trefferschwelle ist zu streng, wenn die Pixel weit weg größer werden als die Schwelle. Ich lasse sie deshalb mit der Entfernung wachsen (HIT_EPS + HIT_EPS_SLOPE * t). Bei einem sehr flachen Strahl, der aus 0,3 Höhe auf den Boden zukriecht, brauche ich 39 statt 78 Schritte. Die Zahlen habe ich mit demselben Algorithmus nachgerechnet, im Test steht die Aussage, dass die wachsende Schwelle weniger Schritte braucht.
Der ganze Trick hängt daran, dass die Funktion nie zu viel Abstand verspricht. Die Szene hält das ein, auch mit Smooth Union und Drehungen, der Test vergleicht dafür 12 000 zufällige Punktpaare. Wer eine Form streckt oder die Oberfläche mit einem Sinus verbiegt, überschätzt den Abstand, und der Strahl springt durch dünne Objekte durch. Üblich ist, den Schritt dann mit einem Faktor unter 1 zu kürzen.
Am Horizont kostet der Boden am meisten, siehe Heatmap. Der Nebel (exp(-0.0025 * t * t)) sorgt dafür, dass man die Lücken dort kaum sieht.
Die Obergrenze der Schleife ist ein Uniform. Das geht in WebGL2, bei GLSL ES 1.00 hätte sie konstant sein müssen. Für schwache Grafikkarten rendert der Chip res in eine halb so große Textur und skaliert sie hoch. Gemessen habe ich auf einer Apple-GPU 60 Bilder pro Sekunde, mit SwiftShader (also komplett in Software) bei 960 mal 540 noch 34, bei halber Auflösung wieder 60. Kantenglättung gibt es nicht, die Kanten sind stufig.
Den Code findest du im Repo unter ray-marching. Alle Demos der Serie sammelt das Projekt Game Dev Snippets.