Ich veröffentliche das nächste Kapitel meines
Vorlesungskurses über Computergrafik (
hier können Sie das Original auf Russisch
lesen , obwohl die englische Version neuer ist). Dieses Mal ist das Gesprächsthema das
Zeichnen von Szenen mithilfe von Raytracing . Wie üblich versuche ich, Bibliotheken von Drittanbietern zu meiden, da die Schüler dadurch unter die Haube schauen.
Es gibt bereits viele ähnliche Projekte im Internet, aber fast alle zeigen fertige Programme, die äußerst schwer zu verstehen sind. Hier zum Beispiel ein sehr berühmtes
Rendering-Programm, das auf eine Visitenkarte passt . Ein sehr beeindruckendes Ergebnis, aber das Verständnis dieses Codes ist sehr schwierig. Mein Ziel ist es nicht zu zeigen, wie ich kann, sondern im Detail zu erklären, wie ich dies reproduzieren kann. Darüber hinaus scheint mir diese Vorlesung nicht einmal als Schulungsmaterial für Computergrafik nützlich zu sein, sondern als Programmierwerkzeug. Ich werde konsequent zeigen, wie man von Grund auf zum Endergebnis kommt: wie man ein komplexes Problem in elementare lösbare Stufen zerlegt.
Achtung: Es macht keinen Sinn, nur meinen Code zu betrachten und diesen Artikel nur mit einer Tasse Tee in der Hand zu lesen. In diesem Artikel können Sie eine Tastatur greifen und Ihre eigene Engine schreiben. Er wird sicherlich besser sein als meiner. Oder ändern Sie einfach die Programmiersprache!Also, heute werde ich zeigen, wie man solche Bilder zeichnet:

Stufe eins: Speichern Sie das Bild auf der Festplatte
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. Das erste, was wir tun müssen, ist das Bild auf der Festplatte zu speichern.
Hier liegt der Code, mit dem Sie dies tun können. Lassen Sie mich Ihnen die Hauptdatei geben:
#include <limits> #include <cmath> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" void render() { const int width = 1024; const int height = 768; std::vector<Vec3f> framebuffer(width*height); for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } std::ofstream ofs; // save the framebuffer to file ofs.open("./out.ppm"); 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)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j]))); } } ofs.close(); } int main() { render(); return 0; }
In der Hauptfunktion wird nur die Funktion render () aufgerufen, sonst nichts. Was ist in der Funktion render () enthalten? Zunächst definiere ich ein Bild als eindimensionales Array von Framebuffer-Werten vom Typ Vec3f. Dies sind einfache dreidimensionale Vektoren, die uns die Farbe (r, g, b) für jedes Pixel geben.
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. Wenn Sie in anderen Formaten speichern möchten, empfehle ich dennoch, eine Bibliothek eines Drittanbieters
anzuschließen , z. B.
stb . Dies ist eine wunderbare Bibliothek: Es reicht aus, eine Header-Datei stb_image_write.h in das Projekt aufzunehmen, und dies ermöglicht das Speichern auch in PNG, sogar in JPG.
Insgesamt besteht das Ziel dieser Phase darin, sicherzustellen, dass wir a) ein Bild im Speicher erstellen und dort verschiedene Farbwerte schreiben können. B) das Ergebnis auf der Festplatte speichern, damit es in einem Programm eines Drittanbieters angezeigt werden kann. Hier ist das Ergebnis:

Stufe zwei, die schwierigste: direktes Raytracing
Dies ist die wichtigste und schwierigste Phase der gesamten Kette. Ich möchte eine Kugel in meinem Code definieren und auf dem Bildschirm anzeigen, ohne mich um Materialien oder Beleuchtung zu kümmern. So sollte unser Ergebnis aussehen:

Der Einfachheit halber gibt es in meinem Repository ein Commit für jede Phase. Mit Github können Sie Ihre Änderungen bequem anzeigen.
Hier zum Beispiel , was sich im zweiten Commit gegenüber dem ersten geändert hat.
Zunächst: Was brauchen wir, um eine Kugel im Speicher des Computers darzustellen? Vier Zahlen reichen uns: ein dreidimensionaler Vektor mit dem Mittelpunkt der Kugel und ein Skalar, der den Radius beschreibt:
struct Sphere { Vec3f center; float radius; Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {} bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const { Vec3f L = center - orig; float tca = L*dir; float d2 = L*L - tca*tca; if (d2 > radius*radius) return false; float thc = sqrtf(radius*radius - d2); t0 = tca - thc; float t1 = tca + thc; if (t0 < 0) t0 = t1; if (t0 < 0) return false; return true; } };
Die einzige nicht triviale Sache in diesem Code ist eine Funktion, mit der Sie überprüfen können, ob ein bestimmter Strahl (der von orig in Richtung dir stammt) unsere Kugel schneidet. Eine detaillierte Beschreibung des Algorithmus zur Überprüfung des Schnittpunkts von Strahl und Kugel finden
Sie hier . Ich empfehle dringend, dies zu tun und meinen Code zu überprüfen.
Wie funktioniert Raytracing? Sehr einfach. In der ersten Phase haben wir das Bild einfach mit einem Farbverlauf abgedeckt:
for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } }
Jetzt bilden wir für jedes Pixel einen Strahl, der vom Koordinatenzentrum kommt und durch unser Pixel geht, und prüfen, ob dieser Strahl unsere Kugel schneidet.

Wenn es keinen Schnittpunkt mit der Kugel gibt, setzen wir Farbe1, andernfalls Farbe2:
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { float sphere_dist = std::numeric_limits<float>::max(); if (!sphere.ray_intersect(orig, dir, sphere_dist)) { return Vec3f(0.2, 0.7, 0.8);
An dieser Stelle empfehle ich, einen Bleistift zu nehmen und alle Berechnungen auf Papier zu überprüfen, sowohl den Schnittpunkt eines Strahls mit einer Kugel als auch das Fegen eines Bildes mit Strahlen. Für alle Fälle wird unsere Kamera von folgenden Faktoren bestimmt:
- Bildbreite
- Bildhöhe
- Betrachtungswinkel, fov
- Kamerastandort, Vec3f (0,0,0)
- Blickrichtung entlang der z-Achse in Richtung minus unendlich
Stufe drei: Fügen Sie weitere Kugeln hinzu
Das Schwierigste liegt hinter uns, jetzt ist unser Weg wolkenlos. Wenn wir eine Kugel zeichnen können. dann füge natürlich noch ein paar arbeit hinzu ist nicht schwer.
Hier sehen Sie Änderungen im Code und hier ist das Ergebnis:

Stufe vier: Beleuchtung
Jeder ist gut in unserem Bild, aber das ist einfach nicht genug Licht. Im Rest des Artikels werden wir nur darüber sprechen. Fügen Sie einige Punktlichtquellen hinzu:
struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; };
Echte Beleuchtung zu betrachten ist eine sehr, sehr schwierige Aufgabe, daher werden wir, wie alle anderen auch, das Auge täuschen, indem wir völlig unphysische, aber wahrscheinlichste plausible Ergebnisse ziehen. Erste Bemerkung: Warum ist es im Winter kalt und im Sommer heiß? Denn die Erwärmung der Erdoberfläche hängt vom Einfallswinkel des Sonnenlichts ab. Je höher die Sonne über dem Horizont ist, desto heller wird die Oberfläche beleuchtet. Und umgekehrt, je tiefer der Horizont, desto schwächer. Nun, nachdem die Sonne über dem Horizont untergegangen ist, erreichen uns Photonen überhaupt nicht mehr. In Bezug auf unsere Kugeln: Hier ist unser Strahl, der von der Kamera emittiert wird (keine Beziehung zu Photonen, aufgepasst!). Schnitt mit der Kugel. Wie verstehen wir, wie der Schnittpunkt beleuchtet wird? Sie können einfach den Winkel zwischen dem Normalenvektor an diesem Punkt und dem Vektor betrachten, der die Richtung des Lichts beschreibt. Je kleiner der Winkel, desto besser wird die Oberfläche beleuchtet. Um es noch bequemer zu machen, können Sie einfach das Skalarprodukt zwischen dem Normalenvektor und dem Beleuchtungsvektor nehmen. Ich erinnere mich, dass das Skalarprodukt zwischen zwei Vektoren a und b gleich dem Produkt der Normen der Vektoren durch den Kosinus des Winkels zwischen den Vektoren ist: a * b = | a | | b | cos (alpha (a, b)). Wenn wir Vektoren mit Einheitslänge nehmen, gibt uns das einfachste Skalarprodukt die Intensität der Oberflächenbeleuchtung.
Daher geben wir in der cast_ray-Funktion anstelle einer konstanten Farbe die Farbe unter Berücksichtigung der Lichtquellen zurück:
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { [...] float diffuse_light_intensity = 0; for (size_t i=0; i<lights.size(); i++) { Vec3f light_dir = (lights[i].position - point).normalize(); diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N); } return material.diffuse_color * diffuse_light_intensity; }
Sehen Sie die Änderungen
hier , aber das Ergebnis des Programms:

Stufe fünf: Glänzende Oberflächen
Ein Trick mit einem Skalarprodukt zwischen einem Normalenvektor und einem Lichtvektor nähert sich der Beleuchtung von matten Oberflächen recht gut an, was in der Literatur als diffuse Beleuchtung bezeichnet wird. Was tun, wenn wir glatt und glänzend wollen? Ich möchte dieses Bild bekommen:

Sehen Sie,
wie wenig Änderungen vorgenommen werden mussten. Kurz gesagt, die Reflexionen auf glänzenden Oberflächen sind umso heller, je kleiner der Winkel zwischen der Blickrichtung und der Richtung des
reflektierten Lichts ist. Nun, die Ecken werden wir natürlich genau wie zuvor durch skalare Produkte zählen.
Diese Gymnastik mit hellmatten und glänzenden Oberflächen ist als
Phong-Modell bekannt . Das Wiki enthält eine ziemlich detaillierte Beschreibung dieses Beleuchtungsmodells und liest sich gut, wenn es parallel zu meinem Code verglichen wird. Hier ist ein Schlüsselbild zu verstehen:

Stufe Sechs: Schatten
Warum haben wir Licht, aber keine Schatten? Durcheinander! Ich möchte dieses Bild:
Mit nur sechs Codezeilen können wir dies erreichen: Beim Zeichnen jedes Punkts stellen wir nur sicher, dass die Lichtquelle die Objekte unserer Szene nicht schneidet. Wenn dies der Fall ist, wird die aktuelle Lichtquelle übersprungen. Es gibt nur eine kleine Subtilität: Ich verschiebe den Punkt ein wenig in Richtung der Normalen:
Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
Warum? Ja, es ist nur so, dass unser Punkt auf der Oberfläche des Objekts liegt und (mit Ausnahme von numerischen Fehlern) jeder Strahl von diesem Punkt unsere Szene durchquert.
Schritt sieben: Reflexionen
Das ist unglaublich, aber um unserer Szene Reflexionen hinzuzufügen, müssen wir nur drei Codezeilen hinzufügen:
Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
Überzeugen Sie sich selbst: Am Schnittpunkt mit dem Objekt zählen wir einfach den reflektierten Strahl (die Funktion aus der Berechnung der Unebenheiten hat sich als nützlich erwiesen!) Und rufen die Funktion cast_ray rekursiv in Richtung des reflektierten Strahls auf. Stellen Sie sicher, dass Sie mit der
Rekursionstiefe spielen . Ich setze sie auf vier. Beginnen Sie bei Null. Was wird sich im Bild ändern? Hier ist mein Ergebnis mit einer funktionierenden Reflexion und einer Tiefe von vier:

Stufe acht: Brechung
Durch das Lernen, Reflexionen zu zählen, werden
Refraktionen genau gleich gezählt . Eine Funktion, mit der Sie die Richtung des gebrochenen Strahls (
gemäß dem Snellschen
Gesetz ) berechnen können, und drei Codezeilen in unserer rekursiven Funktion cast_ray. Hier ist das Ergebnis, bei dem die nächste Kugel zu „Glas“ wurde, sie bricht und reflektiert leicht:

Stufe neun: Fügen Sie weitere Objekte hinzu
Warum sind wir alle ohne Milch, aber ohne Milch? Bis zu diesem Moment haben wir nur Kugeln gerendert, da dies eines der einfachsten nicht trivialen mathematischen Objekte ist. Und fügen wir ein Stück des Flugzeugs hinzu. Ein Klassiker des Genres ist ein Schachbrett. Dafür reichen uns ein
Dutzend Linien in einer Funktion, die den Schnittpunkt des Strahls mit der Szene berücksichtigt.
Nun, hier ist das Ergebnis:

Wie ich versprochen habe, zählen genau 256 Codezeilen
für sich selbst !
Stufe zehn: Hausaufgaben
Wir haben einen ziemlich langen Weg zurückgelegt: Wir haben gelernt, wie man Objekte zur Szene hinzufügt, um eine ziemlich komplizierte Beleuchtung zu berücksichtigen. Lassen Sie mich zwei Aufgaben als Hausaufgabe hinterlassen. Absolut alle Vorbereitungsarbeiten wurden bereits im Zweig
homework_assignment durchgeführt . Für jeden Job sind maximal zehn Codezeilen erforderlich.
Aufgabe eins: Umgebungskarte
Im Moment, wenn der Strahl die Szene nicht kreuzt, setzen wir ihn einfach auf eine konstante Farbe. Und warum eigentlich dauerhaft? Nehmen wir ein sphärisches Foto (Datei
envmap.jpg ) auf und verwenden es als Hintergrund! Um das Leben einfacher zu machen, habe ich unser Projekt mit der stb-Bibliothek verknüpft, um die Arbeit mit JPEGs zu vereinfachen. Dies sollte ein Render wie dieser sein:

Die zweite Aufgabe: Quacksalber!
Wir können sowohl Kugeln als auch Ebenen rendern (siehe Schachbrett). Fügen wir also eine Zeichnung von triangulierten Modellen hinzu! Ich schrieb Code zum Lesen des Dreiecksgitters und fügte dort eine Strahl-Dreieck-Schnittfunktion hinzu. Jetzt sollte es völlig trivial sein, unserer Szene eine Ente hinzuzufügen!

Fazit
Meine Hauptaufgabe ist es, Projekte zu zeigen, die interessant (und einfach!) Sind. Zum Programmieren hoffe ich wirklich, dass ich es schaffen kann. Dies ist sehr wichtig, da ich davon überzeugt bin, dass ein Programmierer viel und mit Geschmack schreiben sollte. Ich weiß nichts über Sie, aber die persönliche Buchhaltung und ein Pionier mit vergleichbarer Codekomplexität ziehen mich überhaupt nicht an.
Zweihundertfünfzig Raytracing-Zeilen können tatsächlich in wenigen Stunden geschrieben werden.
Fünfhundert Zeilen Software-Rasterizer können in wenigen Tagen gemastert werden. Das nächste Mal werden wir das
Rakecasting sortieren und gleichzeitig die einfachsten Spiele zeigen, die meine Schüler im ersten Jahr im Rahmen des Unterrichts in C ++ schreiben. Bleib dran!