Jeder kennt (z.B. aus Computerspielen) die künstlichen, teilweise sogar fotorealistischen, 3D-Computergrafiken welche durch Raytracing berechnet werden. In den folgenden Beiträgen werde ich die Mathematik, die der Berechnung von 3D-Bildern zugrunde liegt, erarbeiten und einen Raytracer selbst implementieren. Natürlich gibt es bereits unzählige fertige Raytracing-Engines, die z.B. durch Hardwarebeschleunigung viel effektiver arbeiten und über sehr ausgereifte Effekte verfügen. Praktisch alle Probleme rund um das Raytracing wurden also bereits gelöst und eigentlich scheint es wenig sinnvoll das „Rad erneut zu erfinden“. Das Ziel dieses Projekts ist es daher nicht in irgend einem Aspekt besser zu sein, als bereits vorhandene Raytracer, sondern schlichtweg ein sehr tiefes Verständnis für diese Technik zu erlangen. Bei der Entwicklung des Raytracers werde ich daher vollständig auf externe Bibliotheken verzichten und alle Funktionen selbst implementieren.
Das Grundprizip
Wenn wir ein 3D-Bild auf z.B. einem Computermonitor betrachten, so sieht es so aus, als ob sich die dreidimensionale Szene hinter dem Monitor befinden würde. Der Monitor selbst erscheint uns wie ein Fenster, durch das wir die Szene betrachten. In Wirklichkeit besteht das Bild auf dem Monitor natürlich aus einzelnen Pixeln, von denen jedes Pixel eine andere Farbe hat. Die einzelnen Pixelfarben möchten wir berechnen.
Wir möchten also das Bild einer dreidimensionalen Szene (hier: zwei Kugeln) auf einer Abbildungsebene (Monitor), welche sich zwischen dem Auge des Betrachters und der Szene befindet, berechnen. Dazu berechnen wir ausgehend von dem Auge einen Strahl durch jeden einzelnen Pixel im Pixelgitter der Abbildungsebene und verfolgen den Strahl so lange durch die Szene, bis er auf ein Objekt stößt. Die Farbe dieses Objekts ist dann die Farbe des jeweiligen Pixels.
Um einen solchen Strahl zu beschreiben definieren wir zunächst die Klasse Ray:
public class Ray { public Vector3D Start { get; private set; } public Vector3D Direction { get; private set; } public Ray(Vector3D start, Vector3D direction) { this.Start = start; this.Direction = direction.normalize(); } } }
Ein Ray besteht also aus zwei Vektoren. Seiner Startposition (hier dem Auge des Betrachters) und seiner Richtung. Die Klasse Vector3D habe ich hier beschrieben. Der Richtungsvektor „Direction“ wird im Konstruktor eines Rays normalisiert. Er hat also immer die Länge 1.
Die Szene, Objekte und Primitive
Als Szene bezeichnen wir die Menge aller geometrischen Objekte, welche durch unseren Raytracer auf die Abbildungsebene abgebildet werden sollen. Ein Objekt (z.B. ein Haus, ein Baum, etc.) besteht im Allgemeinen aus vielen kleinen Primitiven. Ein Primitiv (z.B. ein Dreieck oder Viereck) ist ein elementarer Baustein einer Szene, der vom Raytracer direkt verarbeitet werden kann. Alle aufwändigeren Objekte (Häuser, etc.) müssen aus vielen kleinen Primitiven zusammengesetzt werden.
Definieren wir zunächst die abstrakte Klasse Primitive, von der später alle konkreten Primitive (Dreiecke, Vierecke, etc.) abgeleitet werden:
abstract public class Primitive { public Color color { get; private set; } public abstract PointOnPrimitive RayTrace(Ray ray); public Primitive(Color color) { this.color = color; } }
Jedes Primitiv muss eine Farbe haben. Deshalb muss diese bereits beim Konstruktoraufruf angegeben werden. Weiterhin verfügt jedes Primitiv über eine Methode namens RayTrace, welche beim Aufruf einen Ray als Parameter erhält. In dieser Methode berechnet das Primitiv, ob der Ray seine Oberfläche schneidet. Ist dies nicht der Fall, wird null zurückgegeben. Gibt es einen Schnittpunkt, wird ein PointOnPrimitive zurückgegeben. Dieses Objekt beschreibt einen Schnittpunkt zwischen einem Ray und einem Primitiv.
Die abstrakte Klasse PointOnPrimitive sieht wie folgt aus:
abstract public class PointOnPrimitive { public double distanceToRayStart { get; private set; } public abstract Vector3D ToPointInSpace(); public abstract Color GetColor(); public abstract Vector3D GetNormal(); public abstract Primitive GetPrimitive(); public PointOnPrimitive(double distanceToRayStart) { this.distanceToRayStart = distanceToRayStart; } }
Je nachdem, um was für ein Primitiv es sich handelt, kann es verschiedene Implementierungen dieser abstrakten Klasse geben, um die wir uns später kümmern. Schauen wir uns zunächst die Methoden dieser Klasse an:
Die Methode ToPointInSpace() berechnet die Position des PointOnPrimitive im Raum, d.h. seine X, Y und Z Koordinate, und gibt diesen als Vector3D zurück. Dieser Punkt hat einen Abstand von vom Startpunkt des Rays (bzw. des Beobachters), welchen wir mit distanceToRayStart abfragen können. Wenn wir alle Schnittpunkte eines Rays mit allen Primitiven in der Szene berechnen, so wird der Ray in der Regel mehrere Primitive schneiden. Für die Farbe des Pixels auf der Abbildungsebene ist nur der erste Schnittpunkt mit einem Primitiv von Bedeutung, da alle hinteren Primitive von dem vorderen Primitiv verdeckt sind. Finden wir also zu einem Ray mehrere PointOnPrimitives, so müssen wir nur den PointOnPrimitive mit dem geringsten distanceToRayStart verwenden. Mit GetColor() können wir die Farbe des Primitivs, auf dem der PointOnPrimitive liegt, abfragen. Mit GetPrimitive() erhalten wir das Primitiv selbst.
Die Helligkeit eines PointOnPrimitive hängt von dem Winkel zwischen dem Lichteinfall und der Oberfläche des Primitivs ab, denn eine Fläche, die quer zum einfallenden Licht steht, ist wesentlich heller, als eine Fläche, die vom Licht nicht angestrahlt wird. Um herauszufinden, wie die Fläche, auf dem der PointOnPrimitive liegt, geneigt ist, verwenden wir die Methode GetNormal(). Sie liefert uns einen Normalenvektor zu der Ebene, also einen Vektor, der genau senkrecht auf der Primitivoberfläche steht.
Zurück zur Szene:
Die Szene ist also unser Raum, in dem sich alle Primitive befinden. Zusammen bilden die Primitive z.B. eine Landschaft mit Häusern, Bäumen, etc. Aus Entwicklersicht ist eine Szene etwas, in dem wir Raytracen können. Wir definieren daher nun das Interface IRayTraceable. Jedes Objekt, welches dieses Interface implementiert, repräsentiert also einen Raum mit Primitiven, durch den wir Strahlen bzw. Rays senden können. Ein Ray trifft dabei entweder auf ein Primitiv oder er passiert den Raum ungehindert.
Das Interface IRayTraceable sieht wie folgt aus:
public interface IRayTraceable { Vector3D DirectionToSun { get; set; } Color Background { get; set; } double MaxLightening { get; set; } // Gültige Werte von 0 (Originalfarbe) bis 1 (jede Farbe wird bis auf Weiss aufgehellt) double MaxDarkening { get; set; } // Gültige Werte von 0 (Originalfarbe) bis -1 (jede Farbe wird bis auf Schwarz abgedunkelt) PointOnPrimitive RayTrace(Ray ray); bool RayTraceHits(Ray ray); // Liefert true, wenn der Ray auf ein beliebiges Primitive trifft. }
Jede Szene braucht eine Lichtquelle, damit für unterschiedliche Ausrichtungen von Oberflächen unterschiedliche Farben bzw. Helligkeitswerte berechnet werden können. Würde eine Lichtquelle fehlen, dann würde eine Kugel aussehen wie ein flacher einfarbiger Kreis und der dreidimensionale Eindruck ginge verloren. Eine Kugel wirkt erst durch die unterschiedlichen Helligkeits-Schattierungen dreidimensional. Um die Szene nach ihrer Beleuchtungsrichtung zu fragen, gibt es in dem Interface den Vektor „DirectionToSun“. Je nachdem in welchem Winkel das Licht auf eine Oberfläche fällt, wird diese ausgehend von ihrer Originalfarbe aufgehellt oder abgedunkelt. Mit „MaxDarkening“ wird die maximale Abdunkelung einer Oberfläche festgelegt. Ist dieser Wert auf -1, so sind alle Flächen, auf die kein Sonnenlicht fällt absolut schwarz. Wird der Wert größer gewählt (z.B. -0,5) so gibt es ein diffuses Umgebungslicht in der Szene, welches die Abdunkelung begrenzt. Mit „MaxLightening“ wird festgelegt, wie intensiv die Sonneneinstrahlung ist. Wird der Wert auf 1 gesetzt, so ist jede Oberfläche die senkrecht zur Sonnenstrahlung steht, absolut weiß. Wird ein kleinerer Wert gewählt (z.B. 0.8), so wird die Farbe nicht ganz so weit aufgehellt. Einige Rays werden beim Raytracen die Szene passieren, ohne auf ein Primitiv zu treffen. Diese Pixel werden in der Farbe „Background“ des Interfaces dargestellt.
Die Methoden „RayTrace“ und „RayTraceHits“ dienen dazu Strahlen durch die Szene zu senden. Der Strahl bzw. Ray selbst wird als Parameter übergeben. Trifft dieser Ray auf ein Primitiv, so gibt die Methode „RayTrace“ einen PointOnPrimitive zurück, welcher den Schnittpunkt zwischen Ray und Primitiv beschreibt. Trifft der Ray auf kein Primitiv, so wird null zurückgegeben. Die Methode RayTraceHits arbeitet sehr ähnlich. Sie gibt jedoch nur den Wert true wenn der Ray auf ein beliebiges Primitiv trifft, und false wenn der Ray die Szene ungehindert passiert.
Mit diesen Interfaces bzw. abstrakten Klassen haben wir bereits alle wichtigen Schnittstellen in unserem Raytracer beschrieben. In den folgenden Beiträgen werden wir die einzelnen konkreten Klassen, welche diese Schnittstellen verwenden, implementieren.
Pingback: Raytracing Teil 2: Die Kamera | Volkers Blog
Pingback: Raytracing Teil 3: Die Szene | Volkers Blog
Pingback: Raytracing Teil 4: Das Plane-Primitiv | Volkers Blog