Vor einer Woche habe ich
ein weiteres Kapitel aus meinem
Computergrafik-Vorlesungskurs veröffentlicht . Heute kehren wir wieder zum Raytracing zurück, aber diesmal gehen wir etwas weiter als das Rendern trivialer Kugeln. Ich brauche keinen Fotorealismus, für Comic-Zwecke wird eine
solche Explosion , so scheint es mir, niedergehen.
Wie immer steht uns nur ein Bare-Compiler zur Verfügung, es können keine Bibliotheken von Drittanbietern verwendet werden. Ich möchte mich nicht mit Fenstermanagern, Maus- / Tastaturverarbeitung und dergleichen beschäftigen. Das Ergebnis unseres Programms ist ein einfaches Bild, das auf der Festplatte gespeichert wird. Ich jage überhaupt nicht nach Geschwindigkeit / Optimierung, mein Ziel ist es, die Grundprinzipien zu zeigen.
Wie kann man unter solchen Bedingungen insgesamt ein solches Bild in 180 Codezeilen zeichnen?

Lassen Sie mich sogar ein animiertes GIF (sechs Meter) einfügen:

Und jetzt werden wir die gesamte Aufgabe in mehrere Phasen unterteilen:
Stufe eins: Lesen Sie den vorherigen Artikel
Ja, das ist so. Das allererste, was Sie tun müssen, ist das
vorherige Kapitel zu lesen, in dem die Grundlagen des Raytracing behandelt werden. Es ist im Prinzip sehr kurz, alle Reflexionen-Brechungen können nicht gelesen werden, aber zumindest bis zur diffusen Beleuchtung empfehle ich, es zu lesen. Der Code ist recht einfach, die Leute führen ihn sogar auf Mikrocontrollern aus:

Stufe zwei: Zeichne eine Kugel
Zeichnen wir eine Kugel, ohne uns um Materialien oder Beleuchtung zu kümmern. Der Einfachheit halber wird diese Kugel im Zentrum der Koordinaten leben. Über dieses Bild möchte ich bekommen:

Sehen Sie sich den Code
hier an , aber lassen Sie mich Ihnen den wichtigsten direkt im Text des Artikels geben:
#define _USE_MATH_DEFINES #include <cmath> #include <algorithm> #include <limits> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" const float sphere_radius = 1.5; float signed_distance(const Vec3f &p) { return p.norm() - sphere_radius; } bool sphere_trace(const Vec3f &orig, const Vec3f &dir, Vec3f &pos) { pos = orig; for (size_t i=0; i<128; i++) { float d = signed_distance(pos); if (d < 0) return true; pos = pos + dir*std::max(d*0.1f, .01f); } return false; } int main() { const int width = 640; const int height = 480; const float fov = M_PI/3.; std::vector<Vec3f> framebuffer(width*height); #pragma omp parallel for for (size_t j = 0; j<height; j++) { // actual rendering loop for (size_t i = 0; i<width; i++) { float dir_x = (i + 0.5) - width/2.; float dir_y = -(j + 0.5) + height/2.; // this flips the image at the same time float dir_z = -height/(2.*tan(fov/2.)); Vec3f hit; if (sphere_trace(Vec3f(0, 0, 3), Vec3f(dir_x, dir_y, dir_z).normalize(), hit)) { // the camera is placed to (0,0,3) and it looks along the -z axis framebuffer[i+j*width] = Vec3f(1, 1, 1); } else { framebuffer[i+j*width] = Vec3f(0.2, 0.7, 0.8); // background color } } } std::ofstream ofs("./out.ppm", std::ios::binary); // save the framebuffer to file ofs << "P6\n" << width << " " << height << "\n255\n"; for (size_t i = 0; i < height*width; ++i) { for (size_t j = 0; j<3; j++) { ofs << (char)(std::max(0, std::min(255, static_cast<int>(255*framebuffer[i][j])))); } } ofs.close(); return 0; }
Die Vektorklasse befindet sich in der Dateiometry.h, ich werde sie hier nicht beschreiben: Erstens ist dort alles trivial, einfache Manipulation von zwei- und dreidimensionalen Vektoren (Addition, Subtraktion, Zuweisung, Multiplikation mit einem Skalar, Skalarprodukt) und zweitens
gbg hat es bereits im Rahmen einer Vorlesung über Computergrafik
ausführlich beschrieben .
Ich speichere das Bild im
ppm-Format . Dies ist der einfachste Weg zum Speichern von Bildern, jedoch nicht immer der bequemste für die weitere Anzeige.
In der main () -Funktion habe ich also zwei Zyklen: Der zweite Zyklus speichert das Bild einfach auf der Festplatte, und der erste Zyklus durchläuft alle Pixel des Bildes, sendet einen Strahl von der Kamera durch dieses Pixel aus und prüft, ob dieser Strahl unsere Kugel schneidet.
Achtung, die Hauptidee des Artikels: Wenn wir im letzten Artikel den Schnittpunkt eines Strahls und einer Kugel analytisch betrachtet haben, zähle ich ihn jetzt numerisch. Die Idee ist einfach: Die Kugel hat eine Gleichung der Form x ^ 2 + y ^ 2 + z ^ 2 - r ^ 2 = 0; aber im allgemeinen ist die Funktion f (x, y, z) = x ^ 2 + y ^ 2 + z ^ 2 - r ^ 2 im gesamten Raum definiert. Innerhalb der Kugel hat die Funktion f (x, y, z) negative Werte und außerhalb der Kugel ist sie positiv. Das heißt, die Funktion f (x, y, z) legt den Abstand (mit einem Vorzeichen!) Zu unserer Kugel für den Punkt (x, y, z) fest. Deshalb gleiten wir einfach entlang des Strahls, bis uns entweder langweilig wird oder die Funktion f (x, y, z) negativ wird. Die Kugel_trace () -Funktion macht genau das.
Stufe drei: Primitive Beleuchtung
Lassen Sie uns die einfachste diffuse Beleuchtung codieren. Ich möchte ein solches Bild am Ausgang erhalten:

Wie im vorherigen Artikel habe ich zur Vereinfachung des Lesens einen Schritt = ein Commit ausgeführt. Änderungen sind hier zu
sehen .
Für diffuses Licht reicht es nicht aus, den Schnittpunkt des Strahls mit der Oberfläche zu berechnen. Wir müssen den Normalenvektor zur Oberfläche an diesem Punkt kennen. Ich habe diesen Normalenvektor durch einfache
endliche Unterschiede in unserer Funktion des Abstands zur Oberfläche erhalten:
Vec3f distance_field_normal(const Vec3f &pos) { const float eps = 0.1; float d = signed_distance(pos); float nx = signed_distance(pos + Vec3f(eps, 0, 0)) - d; float ny = signed_distance(pos + Vec3f(0, eps, 0)) - d; float nz = signed_distance(pos + Vec3f(0, 0, eps)) - d; return Vec3f(nx, ny, nz).normalize(); }
Im Prinzip kann natürlich, da wir eine Kugel zeichnen, die Normalität viel einfacher erhalten werden, aber ich habe dies mit einer Reserve für die Zukunft getan.
Stufe vier: Zeichnen wir ein Muster auf unsere Kugel
Und lassen Sie uns zum Beispiel ein Muster in unserer Region zeichnen:

Dazu habe ich im vorherigen Code
nur zwei Zeilen geändert
!Wie habe ich das gemacht? Natürlich habe ich keine Texturen. Ich habe gerade die Funktion g (x, y, z) = sin (x) * sin (y) * sin (z) genommen; es wird wieder im gesamten Raum definiert. Wenn mein Strahl irgendwann die Kugel kreuzt, bestimmt der Wert der Funktion g (x, y, z) an diesem Punkt die Farbe des Pixels für mich.
Achten Sie übrigens auf konzentrische Kreise um die Kugel - dies sind Artefakte meiner numerischen Berechnung des Schnittpunkts.
Fünfter Schritt: Verschiebungsabbildung
Warum wollte ich dieses Muster zeichnen? Und er wird mir helfen, einen solchen Igel zu zeichnen:

Wo mein Muster schwarz war, möchte ich ein Loch in unsere Kugel schieben, und wo es weiß war, strecken Sie im Gegenteil den Buckel.
Ändern Sie dazu einfach
die drei Zeilen in unserem Code:
float signed_distance(const Vec3f &p) { Vec3f s = Vec3f(p).normalize(sphere_radius); float displacement = sin(16*sx)*sin(16*sy)*sin(16*sz)*noise_amplitude; return p.norm() - (sphere_radius + displacement); }
Das heißt, ich habe die Berechnung des Abstands zu unserer Oberfläche geändert und ihn als x ^ 2 + y ^ 2 + z ^ 2 - r ^ 2 - sin (x) * sin (y) * sin (z) definiert. Tatsächlich haben wir eine
implizite Funktion definiert.
Schritt 6: Eine weitere implizite Funktion
Und warum bewerte ich das Sinusprodukt nur für Punkte, die auf der Oberfläche unserer Kugel liegen? Definieren wir unsere implizite Funktion wie folgt neu:
float signed_distance(const Vec3f &p) { float displacement = sin(16*px)*sin(16*py)*sin(16*pz)*noise_amplitude; return p.norm() - (sphere_radius + displacement); }
Der Unterschied zum vorherigen Code ist sehr gering, es ist besser,
den Unterschied zu
sehen . Hier ist das Ergebnis:

So können wir getrennte Komponenten in unserem Objekt definieren!
Schritt sieben: Pseudozufälliges Rauschen
Das vorherige Bild ähnelt bereits einer Explosion aus der Ferne, aber das Produkt der Sinusse weist ein zu regelmäßiges Muster auf. Wir würden mehr "zerrissene", mehr "zufällige" Funktionen brauchen ...
Perlins Lärm wird uns helfen. Hier ist so etwas, das uns viel besser passen würde als das Produkt der Sinusse:

Das Erzeugen eines solchen Rauschens ist ein wenig thematisch, aber hier ist die Hauptidee: Sie müssen zufällige Bilder mit unterschiedlichen Auflösungen erzeugen und glätten, um so etwas zu erhalten:

Und dann fasse sie einfach zusammen:

Lesen Sie
hier und
hier mehr .
Fügen wir
einen Code hinzu , der dieses Rauschen erzeugt, und erhalten dieses Bild:

Bitte beachten Sie, dass ich im Rendering-Code überhaupt nichts geändert habe, sondern nur die Funktion, die unsere Kugel „faltig“ macht.
Stufe acht, Finale: Farbe hinzufügen
Das einzige, was ich
an diesem Commit geändert habe, ist, dass ich anstelle einer einheitlichen weißen Farbe eine Farbe angewendet habe, die linear von der Menge des angewendeten Rauschens abhängt:
Vec3f palette_fire(const float d) { const Vec3f yellow(1.7, 1.3, 1.0);
Dies ist ein einfacher linearer Verlauf zwischen den fünf Schlüsselfarben. Nun, hier ist das Bild!

Fazit
Diese Raytracing-Technik wird Ray Marching genannt. Die Hausaufgaben sind einfach: Überqueren Sie den vorherigen Ray Tracer mit Blackjack und Reflexionen mit unserer Explosion, damit die Explosion auch alles um sich herum beleuchtet! Übrigens fehlt dieser Explosion die Transluzenz.