256 Zeilen nacktes C ++: Wir schreiben in wenigen Stunden einen Ray Tracer von Grund auf

  • Tutorial
Ich veröffentliche das nächste Kapitel aus meiner Vorlesung über Computergraphik ( hier können Sie das Original auf Russisch lesen , obwohl die englische Version neuer ist). Diesmal das Gesprächsthema - Zeichnen von Szenen mit Ray Tracing . Wie immer versuche ich, Bibliotheken von Drittanbietern zu vermeiden, da dies die Schüler dazu zwingt, unter die Haube zu schauen.

Solche Projekte im Internet sind bereits das Meer, aber fast alle zeigen komplette Programme, die äußerst schwer zu verstehen sind. Zum Beispiel ein sehr bekanntes Rendering-Programm, das auf eine Visitenkarte klettert.. Sehr beeindruckendes Ergebnis, aber diesen Code zu verstehen, ist sehr schwierig. Mein Ziel ist nicht zu zeigen, wie ich kann, sondern im Detail zu sagen, wie man so etwas reproduzieren kann. Darüber hinaus scheint es mir, dass gerade diese Vorlesung sogar als Lernmaterial zur Computergrafik nützlich ist, sondern eher als Programmierhandbuch. Ich werde konsequent zeigen, wie man von vorne an zum Endergebnis kommt: wie man eine komplexe Aufgabe in elementare lösbare Schritte zerlegt.

Achtung: Wenn Sie nur meinen Code beachten und diesen Artikel nur mit einer Tasse Tee in der Hand lesen, ist dies nicht sinnvoll. In diesem Artikel erfahren Sie, wie Sie die Tastatur aufnehmen und Ihre eigene Engine schreiben. Er wird sicherlich besser als meiner sein. Nun, oder einfach die Programmiersprache wechseln!

So werde ich heute zeigen, wie man solche Bilder zeichnet:



Erster Schritt: Speichern Sie das Image auf der Festplatte


Ich möchte mich nicht mit Fenstermanagern, der Handhabung von Maus / Tastatur 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 Image auf der Festplatte zu speichern. Hier ist der Code, mit dem Sie dies tun können. Lassen Sie mich ihm 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 steckt in der render () - Funktion? Zuallererst definiere ich ein Bild als ein 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 lebt in der Datei geometrie.h, ich werde sie hier nicht beschreiben: erstens ist alles eine einfache, einfache Manipulation von zwei- und dreidimensionalen Vektoren (Addition, Subtraktion, Zuordnung, Skalarmultiplikation, Skalarproduktion) und zweitens gbg hat es bereits ausführlich in einem Kurs über Computergraphik beschrieben.

Ich speichere das Bild im ppm-Format; Dies ist der einfachste Weg, Bilder zu speichern, obwohl dies für die weitere Anzeige nicht immer der bequemste ist. Wenn Sie in anderen Formaten speichern möchten, empfehle ich, eine Bibliothek eines Drittanbieters anzuschließen, z. B. stb . Dies ist eine hervorragende Bibliothek: Es reicht aus, eine stb_image_write.h -Headerdatei in das Projekt aufzunehmen. Dadurch können Sie mindestens in png, mindestens in jpg, speichern.

Das Ziel dieser Phase ist es also sicherzustellen, dass wir a) ein Bild im Speicher erstellen und dort verschiedene Farbwerte aufzeichnen 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: direkte Strahlenverfolgung


Dies ist die wichtigste und schwierigste Stufe der gesamten Kette. Ich möchte eine Sphäre in meinem Code definieren und auf dem Bildschirm anzeigen, ohne sich mit Materialien oder Beleuchtung zu beschäftigen. So sollte unser Ergebnis aussehen: Der



Einfachheit halber gibt es für jede Phase in meinem Repository ein Commit; Github macht es sehr bequem, Ihre Änderungen anzuzeigen. Was sich beispielsweise beim zweiten Commit im Vergleich zum ersten Commit geändert hat.

Zunächst einmal: Was brauchen wir, um die Kugel im Speicher des Computers darzustellen? Vier Zahlen sind genug für uns: ein dreidimensionaler Vektor mit dem Mittelpunkt einer 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;
    }
};

Das einzige Nicht-Triviale in diesem Code ist eine Funktion, mit der Sie prüfen können, ob ein bestimmter Strahl (der aus einem Ursprungspunkt in Dir-Richtung stammt) sich mit unserer Kugel schneidet. Eine detaillierte Beschreibung des Algorithmus zur Überprüfung des Schnittpunkts von Strahl und Kugel finden Sie hier . Ich empfehle es sehr, dies zu tun und meinen Code zu überprüfen.

Wie funktioniert Ray Tracing? Sehr einfach. Im ersten Schritt haben wir das Bild nur mit einem Farbverlauf überstrichen:

    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);
        }
    }

Nun werden wir für jedes Pixel einen Strahl bilden, der vom Mittelpunkt der Koordinaten ausgeht und durch unser Pixel geht, und prüfen, ob dieser Strahl unsere Kugel schneidet.



Wenn es keinen Schnittpunkt mit der Kugel gibt, setzen wir color1, sonst color2:

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); // background color
    }
    return Vec3f(0.4, 0.4, 0.3);
}
void render(const Sphere &sphere) {
	[...]
    for (size_t j = 0; j<height; j++) {
        for (size_t i = 0; i<width; i++) {
            float x =  (2*(i + 0.5)/(float)width  - 1)*tan(fov/2.)*width/(float)height;
            float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.);
            Vec3f dir = Vec3f(x, y, -1).normalize();
            framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere);
        }
    }
	[...]
}

An dieser Stelle empfehle ich, einen Stift zu nehmen und alle Berechnungen auf Papier zu überprüfen, sowohl den Schnittpunkt des Strahls mit der Kugel als auch das Durchstreichen des Bildes durch die Strahlen. Für den Fall, dass unsere Kamera durch folgende Dinge definiert wird:

  • Bildbreite
  • Bildhöhe
  • Betrachtungswinkel, fov
  • Kamerastandort, Vec3f (0,0,0)
  • Blickrichtung entlang der z-Achse in Richtung minus unendlich

Stufe drei: füge mehr Kugeln hinzu


Das Aller Schwierigste ist vorbei, jetzt ist unser Weg ungetrübt. Wenn wir eine Kugel zeichnen können. dann fügen Sie natürlich noch etwas Arbeit hinzu. Schauen Sie sich die Änderungen im Code an und hier ist das Ergebnis:



Vierte Stufe: Beleuchtung


Jeder ist gut in unserem Bild, aber das reicht einfach nicht aus. Im weiteren Verlauf des Artikels werden wir nur darüber sprechen. Fügen Sie einige Lichtquellen hinzu:

struct Light {
    Light(const Vec3f &p, const float &i) : position(p), intensity(i) {}
    Vec3f position;
    float intensity;
};

Die Betrachtung der realen Beleuchtung ist eine sehr, sehr schwierige Aufgabe. Daher werden wir, wie alle anderen auch, das Auge täuschen, indem sie vollständig nicht-physische, aber so weit wie möglich glaubhafte Ergebnisse zeichnen. Erster Hinweis: Warum ist es im Winter kalt und im Sommer heiß? Denn die Erwärmung der Erdoberfläche hängt vom Sonnenlichtwinkel ab. Je höher die Sonne über dem Horizont ist, desto heller leuchtet die Oberfläche. Umgekehrt gilt: Je niedriger der Horizont, desto schwächer. Nun, nachdem die Sonne unter dem Horizont untergegangen ist, erreichen uns die Photonen nicht einmal. In Bezug auf unsere Sphären: Hier ist unser Strahl, der von der Kamera abgestrahlt wird (kein Bezug zu Photonen, aufgepasst!). Mit der Sphäre gekreuzt. 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 ist die Oberfläche beleuchtet. Um es noch bequemer zu machen, können Sie einfach ein Skalarprodukt zwischen dem Normalenvektor und dem Lichtvektor nehmen. Ich erinnere daran, dass die Produktion zwischen den beiden Vektoren a und b dem Produkt der Normen der Vektoren und dem Cosinus des Winkels zwischen den Vektoren entspricht: a * b = | a | | b | cos (alpha (a, b)). Wenn wir Vektoren der Längeneinheit nehmen, ergibt das einfachste Skalarprodukt die Intensität der Beleuchtung der Oberfläche.

Daher wird in der cast_ray-Funktion anstelle der konstanten Farbe die Farbe unter Berücksichtigung der Beleuchtungsquellen zurückgegeben:

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


Der Trick mit dem Skalarprodukt zwischen dem Normalenvektor und dem Lichtvektor nähert sich gut der Beleuchtung von undurchsichtigen Oberflächen an, in der Literatur wird dies als diffuse Beleuchtung bezeichnet. Was tun, wenn wir glatt und glänzend sein wollen? Ich möchte dieses Bild erhalten:



Sehen Sie, wie wenig Änderungen vorgenommen werden mussten. Kurz gesagt, die Aufhellung auf glänzenden Oberflächen ist heller, je kleiner der Winkel zwischen Blickrichtung und Richtung des reflektierten Lichts ist. Nun, die Winkel werden wir natürlich durch das Skalarprodukt genau wie zuvor betrachten.

Dieses Fitnessstudio mit matter und glänzender Oberflächenbeleuchtung ist als Phong-Modell bekannt.. Das Wiki enthält eine ziemlich ausführliche Beschreibung dieses Beleuchtungsmodells. Es ist lesbar, wenn es parallel mit meinem Code verglichen wird. Hier ist das Schlüsselbild zu verstehen:


Sechste Stufe: Schatten


Und warum haben wir Licht, aber keine Schatten? Unordnung! Ich möchte dieses Bild: Mit



nur sechs Codezeilen können wir dies erreichen: Beim Zeichnen jedes Punkts stellen wir nur sicher, dass der Strahl nicht die Punktquelle der Lichtobjekte unserer Szene kreuzt, und wenn er sich kreuzt, überspringen Sie die aktuelle Lichtquelle. Es gibt nur eine kleine Feinheit: Ich verschiebe den Punkt in Richtung der Normalen:

Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;

Warum Ja, nur unser Punkt liegt auf der Oberfläche des Objekts, und (außer der Frage nach numerischen Fehlern) wird jeder Strahl von diesem Punkt unsere Szene kreuzen.

Stufe 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; // offset the original point to avoid occlusion by the object itself
    Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1);

Überzeugen Sie sich selbst: Wenn wir mit einem Objekt schneiden, betrachten wir einfach den reflektierten Strahl (die Funktion aus der Berechnung der Trümmer war nützlich!) Und rufen die Funktion cast_ray rekursiv in Richtung des reflektierten Strahls auf. Stellen Sie sicher, dass Sie mit der Tiefe der Rekursion spielen , ich setze sie auf vier, fange von vorne an, was ändert sich im Bild? Hier ist mein Ergebnis mit einer Arbeitsreflexion und einer Tiefe von vier:



Stufe Acht: Brechung


Nachdem sie gelernt haben, Reflexionen zu zählen, werden die Refraktionen genau gleich betrachtet . Mit einer Funktion können wir die Richtung des gebrochenen Strahls ( nach dem Gesetz von Snell ) und drei Codezeilen in unserer rekursiven cast_ray-Funktion berechnen. Hier ist das Ergebnis, bei dem die nächstgelegene Kugel zu "Glas" wurde, sie bricht und reflektiert ein wenig:



Neunte Stufe: Fügen Sie weitere Objekte hinzu


Und warum sind wir alle ohne Milch, aber ohne Milch. Bis zu diesem Punkt haben wir nur Kugeln gerendert, da dies eines der einfachsten nicht trivialen mathematischen Objekte ist. Und fügen wir ein Stück Flugzeug hinzu. Der Klassiker des Genres ist ein Schachbrett. Dafür brauchen wir ein Dutzend Linien in einer Funktion, die den Schnittpunkt des Balkens mit der Bühne berücksichtigt.

Nun, hier ist das Ergebnis:



Wie versprochen, genau 256 Zeilen Code, rechnen Sie selbst !

Stufe zehn: Hausaufgaben


Wir haben einen weiten Weg zurückgelegt: Wir haben gelernt, der Szene Objekte hinzuzufügen, die ziemlich komplexe Beleuchtung zu zählen. Lassen Sie mich zwei Jobs als Hausaufgaben. Absolut alle vorbereitenden Arbeiten wurden bereits im Zweig homework_assignment durchgeführt . Für jede Aufgabe sind maximal zehn Zeilen Code erforderlich.

Aufgabe eins: Umgebungskarte


Im Moment, wenn der Strahl die Szene nicht kreuzt, geben wir ihm einfach eine konstante Farbe. Und warum eigentlich? Machen wir ein kugelförmiges Foto (Datei envmap.jpg ) und verwenden Sie es als Hintergrund! Um das Leben zu erleichtern, habe ich unser Projekt mit der stb-Bibliothek verbunden, um die Arbeit mit zhpegami zu erleichtern. Es sollte so ein Render sein:



Die zweite Aufgabe: Quack!


Wir können sowohl Kugeln als auch Ebenen rendern (siehe Schachbrett). Fügen wir also eine Zeichnung von triangulierten Modellen hinzu! Ich habe Code geschrieben, mit dem Sie ein Raster aus Dreiecken lesen können, und dort eine Kreuzungsfunktion für Strahl-Dreiecke hinzugefügt. Jetzt fügen Sie ein Entlein unserer Szene hinzu, sollte ziemlich trivial sein!



Fazit


Meine Hauptaufgabe ist es, Projekte zu zeigen, die interessant (und einfach!) Sind. Um zu programmieren, hoffe ich wirklich, dass ich es schaffen kann. Dies ist sehr wichtig, da ich davon überzeugt bin, dass der Programmierer viel und mit Geschmack schreiben muss. Ich weiß nicht, wie es Ihnen geht, aber persönlich sind Buchhaltung und Sappers mit vergleichbarer Code-Komplexität überhaupt nicht ansprechend.

Zweihundertfünfzig Zeilen Raytracing können tatsächlich in wenigen Stunden geschrieben werden. In wenigen Tagen können fünfhundert Zeilen des Software-Rasterizers gemastert werden. Beim nächsten Mal werden wir uns mit dem Rajkasting beschäftigen und gleichzeitig die einfachsten Spiele zeigen, die meine Schüler im ersten Jahr im Rahmen des C ++ - Programmiertrainings schreiben. Bleib dran!

Jetzt auch beliebt: