blog · git · desktop · images · contact & privacy · gopher


Baukasten für Raytracing auf der Grafikkarte (GLSL)

Zugegebenermaßen ist das Thema nicht neu. Ich wollte das aber schon länger mal (halbwegs sauber) machen und vorallem selbst machen, statt eine fertige Sache zu nutzen. Außerdem hatte ich hier und da "besondere Ansprüche".

Es geht im Kern um folgendes:

Die Shader der Grafikkarte bieten sich hierfür natürlich prächtig an, zumal kein rekursives Raytracing gemacht werden soll und es sich auch nicht um große Modelle handelt. Es soll ein Experimentierfeld und schnelles Preview sein.

Screenshot

Obiges Bild zeigt das, was wohl jeder als erstes macht, wenn er mit Raytracing anfängt: Eine Kugel. Hier ist der Schnitttest für einen Lichtstrahl besonders einfach, außerdem ist sie schön rund. ;)

Nun will ich mich frei um diese Kugel bewegen können, das heißt, ich muss Augpunkt und virtuelle Bildfläche verschieben. Wie man eine solche Kamera realisieren könnte, habe ich bereits in einem älteren Posting ausführlich beschrieben -- bzw. im dort verlinkten PDF. Damals hatte ich das auch gleich ausprogrammiert, weswegen ein Großteil des Codes bereits verfügbar war.

Die Positionierung der Kamera läuft auf eine Vektor-Matrix-Multiplikation hinaus. Wie man diese Matrix wahlweise mit Quaternionen oder rein mit Matrizen aufstellt, steht in obigem PDF und der Code aus der SpaceSim-Demo erzeugt diese Matrix auch schon. Sie muss also nur an den Shader auf der Grafikkarte weitergegeben werden, damit dort die Transformationen durchgeführt werden können. Uniform-Variablen erledigen das.

Weiterhin braucht es für jeden Pixel einen Punkt auf der virtuellen Bildebene. Diesen kriegt man fast schon geschenkt: Man zeichnet ein Quadrat, das den ganzen Bildschirm ausfüllt. Dieses Quadrat wird an den Vertex-Shader weitergegeben, also jeder der vier Eckpunkte. Über eine varying-Variable können diese Punkte dann an den Fragment-Shader weitergegeben werden, wo das eigentliche Raytracing eines Pixels stattfindet. Das Schöne hierbei ist, dass OpenGL automatisch zwischen den vier Eckpunkten interpoliert -- für jeden Aufruf der main()-Funktion im Fragment-Shader habe ich also einen eindeutigen Punkt auf der Bildebene. Damit kann ich meine Strahlen erzeugen und diese samt dem Augpunkt hinterher so transformieren, dass sie der vorher berechneten Kameraposition entsprechen. Fertig ist die freie Beweglichkeit. :)

Die Drehung der Kamera lässt sich auch wunderbar über den allseits beliebten "Mouse Look" realisieren.

Was dann folgt, ist analog zu Raytracing auf der CPU. Der bereits existierende Code für das Mandelbulb kann also fast 1:1 übernommen werden. Es findet aber bestmöglichst parallelisiert auf der Grafikkarte statt und ist dementsprechend schneller. Die CPU berechnet eigentlich nur die Kameraposition und ein paar Kleinigkeiten.

Dadurch, dass ich meine Koordinatensysteme im CPU-Raytracer und in dem auf der GPU gleich definiert habe, kann ich mich also an eine beliebige Position bewegen, die Informationen ausgeben lassen und diese Situation dann detaillierter rendern:

Auf der Grafikkarte soll es eher flott gehen, weswegen ich da Schatten weggelassen habe. Den Detailreichtum des Mandelbulbs kann ich auch immernoch im CPU-Raytracer erhöhen:

Screenshot

Schatten und weitere Materialeigenschaften (Reflektionen zum Beispiel) wären zwar auch möglich, das ist mir aber nicht so wichtig. Außerdem kostet das einiges an Leistung.

Okay, nun will ich nicht nur das Mandelbulb rendern. Ich würde gerne möglichst komfortabel eine einzige Funktion austauschen, sodass daraus ein Quaternionen Julia-Fraktal wird. Das könnte man entweder dadurch erreichen, dass man das Shader-Programm mit OpenGL-API-Calls entsprechend aus Einzelteilen zusammenbaut. Oder man könnte programmatisch Teile des Codes ersetzen ähnlich wie ein C-Präprozessor das tut.

In der Tat ist die Präprozessor-Idee schon direkt das, was ich tue: Ich verwende den CPP. Im "Hauptprogramm" des Fragment-Shaders habe ich also irgendwo include-Statements stehen, die so an sich aber von GLSL nicht unterstützt werden:

#include OBJECT_FUNCTIONS
#include RAY_FUNCTIONS

Und vor dem Start des Programms jage ich meinen Shader-Quellcode einmal durch den CPP:

cpp -P -DOBJECT_FUNCTIONS=\"$OBJECT\" \
    -DRAY_FUNCTIONS=\"$RAY\" \
    shader_fragment.glsl shader_fragment_final.glsl

Im eigentlichen Programm lade ich dann nur noch "shader_fragment_final.glsl", also kriegt mein Programm eigentlich gar nichts von dieser Aktion mit. Problematisch könnte das aber werden, wenn man auch den Präprozessor von GLSL nutzen will. Gebraucht habe ich den allerdings noch nicht... Natürlich will man das auch nicht jedesmal tippen, also kommt das in ein Shell-Skript.

In "RAY_FUNCTIONS" definiere ich dann, wie Strahlen gehandhabt werden: Werden klassische Schnitttests gemacht, so wie das bei der Kugel der Fall ist? Dann steht in "OBJECT_FUNCTIONS" der konkrete Code für den Schnittest und in "RAY_FUNCTIONS" nur ganz wenig Wrapper-Code.

Oder soll "Ray Marching" gemacht werden, also das sukzessive Abtasten des Strahlenverlaufs? Das ist für algebraische Flächen und Fraktale nötig. Falls ja, dann habe ich global verwendbaren Ray Marching-Code in "RAY_FUNCTIONS" und in "OBJECT_FUNCTIONS" befindet sich nur der Code, der eine bestimmte algebraische Fläche an einem Punkt im Raum auswertet. Den Ray Marching-Code kann ich dann für verschiedene Flächen oder Fraktale wiederverwenden.

Indem ich also faktisch eine Funktion austausche, kann ich Julias rendern:

Screenshot

Für Interessierte liegt der Sourcecode (GPL) hier. Dort in der README steht auch nochmal das ein oder andere.

Mal sehen, was mir die Tage noch für das Ding einfällt. ;)

Kommentare

Christian Buchner merkt an:

Ich hab die Jungs auf www.fractalforums.com mal auf diese Software hingewiesen.

http://www.fractalforums.com/mandelbulb-implementation/glsl-based-gpu-raytracer/msg17988/#msg17988

Ich glaube, am Raymarching kann man noch was verbessern. Statt fixer Schrittweiten gibt es da noch eine verbesserte Methode mit Distance Estimate (DE). Da wird ein Mindestabstand vom Fraktal berechnet (ähnlich einem Hubbard Douady Potential) und dann um diesen Abstand vorangegangen. Die Methode funktioniert aber wohl nicht bei beliebigen impliziten Oberflächen.