Nach dem Erscheinen der Nvidia RTX-Grafikkarten im letzten Sommer hat das Raytracing seine frühere Popularität wiedererlangt. In den letzten Monaten wurde mein Twitter-Feed mit einem endlosen Strom von Grafikvergleichen mit aktiviertem und deaktiviertem RTX gefüllt.
Nachdem ich so viele schöne Bilder bewundert hatte, wollte ich versuchen, den klassischen Forward-Renderer selbst mit einem Raytracer zu kombinieren.
Da ich
unter einem Syndrom der Ablehnung der Entwicklungen anderer Menschen leide , habe ich meine eigene Hybrid-Rendering-Engine basierend auf WebGL1 erstellt. Sie können hier mit dem Demo-Level-Rendering von Wolfenstein 3D mit den Kugeln (die ich aufgrund von Raytracing verwendet habe) spielen.
Prototyp
Ich habe dieses Projekt mit der Erstellung eines Prototyps begonnen und versucht, die
globale Beleuchtung mit Raytracing von Metro Exodus wiederherzustellen.
Der erste Prototyp mit diffuser globaler Beleuchtung (Diffuse GI)Der Prototyp basiert auf einem Forward-Renderer, der die gesamte Geometrie der Szene rendert. Der zum Rastern der Geometrie verwendete Shader berechnet nicht nur die direkte Beleuchtung, sondern sendet auch zufällige Strahlen von der Oberfläche der gerenderten Geometrie aus, um sie mithilfe des Raytracers zu akkumulieren, der die indirekte Reflexion von Licht von nicht glänzenden Oberflächen (Diffuse GI) verwendet.
Im Bild oben können Sie sehen, wie alle Kugeln nur durch indirekte Beleuchtung korrekt beleuchtet werden (Lichtstrahlen werden von der Wand hinter der Kamera reflektiert). Die Lichtquelle selbst ist von einer braunen Wand auf der linken Seite des Bildes bedeckt.
Wolfenstein 3D
Der Prototyp verwendet eine sehr einfache Szene. Es hat nur eine Lichtquelle und es werden nur wenige Kugeln und Würfel gerendert. Dank dessen ist der Raytracing-Code im Shader sehr einfach. Der Brute-Force-Zyklus zur Überprüfung der Kreuzung, bei dem der Strahl auf Schnitt mit allen Würfeln und Kugeln in der Szene getestet wird, ist immer noch schnell genug, damit das Programm ihn in Echtzeit ausführen kann.
Nachdem ich diesen Prototyp erstellt hatte, wollte ich etwas Komplexeres tun, indem ich der Szene mehr Geometrie und viele Lichtquellen hinzufügte.
Das Problem mit einer komplexeren Umgebung ist, dass ich immer noch in der Lage sein muss, Strahlen in der Szene in Echtzeit zu verfolgen. Normalerweise wird eine BVH-Struktur (
Bounding Volume Hierarchy ) verwendet, um den Raytracing-Prozess zu beschleunigen. Meine Entscheidung, dieses Projekt in WebGL1 zu erstellen, ließ dies jedoch nicht zu: Es ist unmöglich, 16-Bit-Daten in eine Textur in WebGL1 zu laden, und Binäroperationen können in einem Shader nicht verwendet werden. Dies erschwert die vorläufige Berechnung und Anwendung von BVH in WebGL1-Shadern.
Deshalb habe ich mich dafür für die Wolfenstein 3D-Demo entschieden. 2013 habe ich in
Shadertoy einen
Fragment-WebGL-Shader erstellt , der nicht nur Wolfenstein-ähnliche Ebenen rendert, sondern auch alle erforderlichen Texturen prozedural erstellt. Aus meiner Erfahrung mit diesem Shader wusste ich, dass Wolfensteins gitterbasiertes Level-Design auch als schnelle und einfache Beschleunigungsstruktur verwendet werden kann und dass die Strahlverfolgung auf dieser Struktur sehr schnell sein wird.
Unten finden Sie einen Screenshot der Demo, den Sie im Vollbildmodus hier abspielen können:
https://reindernijhoff.net/wolfrt .
Kurzbeschreibung
Die Demo verwendet eine Hybrid-Rendering-Engine. Um alle Polygone im Rahmen zu rendern, wird die herkömmliche Rasterung verwendet und das Ergebnis dann mit Schatten, diffusem GI und Reflexionen kombiniert, die durch Raytracing erzeugt werden.
SchattenDiffuse giReflexionenProaktives Rendern
Wolfenstein-Karten können vollständig in ein zweidimensionales 64 × 64-Raster codiert werden. Die in der Demo verwendete Karte basiert auf der
ersten Ebene von Episode 1 von Wolfenstein 3D.
Beim Start wird die gesamte Geometrie erstellt, die zum Übergeben des proaktiven Renderings erforderlich ist. Aus Kartendaten wird ein Maschennetz erzeugt. Außerdem werden Boden- und Deckenebenen sowie separate Maschen für Lichter, Türen und zufällige Kugeln erstellt.
Alle für Wände und Türen verwendeten Texturen sind in einem einzigen Texturatlas verpackt, sodass alle Wände in einem Zeichenaufruf gezeichnet werden können.
Schatten und Beleuchtung
Die direkte Beleuchtung wird in dem Shader berechnet, der für den Vorwärts-Rendering-Durchgang verwendet wird. Jedes Fragment kann (maximal) von vier verschiedenen Quellen beleuchtet werden. Um zu wissen, welche Quellen das Fragment im Shader beeinflussen können, wird beim Start der Demo die Suchtextur vorberechnet. Diese Suchtextur hat eine Größe von 64 x 128 und codiert die Positionen der 4 nächsten Lichtquellen für jede Position im Kartenraster.
varying vec3 vWorldPos; varying vec3 vNormal; void main(void) { vec3 ro = vWorldPos; vec3 normal = normalize(vNormal); vec3 light = vec3(0); for (int i=0; i<LIGHTS_ENCODED_IN_MAP; i++) { light += sampleLight(i, ro, normal); }
Um weiche Schatten für jedes Fragment und jede Lichtquelle zu erhalten, wird eine zufällige Position in der Lichtquelle abgetastet. Unter Verwendung des Raytracing-Codes im Shader (siehe Abschnitt Raytracing unten) wird ein Schattenstrahl zum Abtastpunkt emittiert, um die Sichtbarkeit der Lichtquelle zu bestimmen.
Nach dem Hinzufügen von (Hilfs-) Reflexionen (siehe Abschnitt Reflexion unten) wird der berechneten Farbe des Fragments ein diffuser GI hinzugefügt, indem eine Suche im diffusen GI-Renderziel durchgeführt wird (siehe unten).
Ray Tracing
Obwohl der Prototyp des Raytracing-Codes für diffuse GI mit einem präventiven Shader kombiniert wurde, habe ich mich in der Demo entschieden, diese zu trennen.
Ich habe sie getrennt, indem ich ein zweites Rendering der gesamten Geometrie in ein separates Renderziel (Diffuse GI Render Target) mit einem anderen Shader durchgeführt habe, der nur zufällige Strahlen aussendet, um diffuse GI zu sammeln (siehe Abschnitt „Diffuse GI“ unten). Die in diesem Rendering-Ziel gesammelte Beleuchtung wird zu der im Vorwärts-Rendering-Durchgang berechneten direkten Beleuchtung hinzugefügt.
Durch Trennen des proaktiven Durchgangs und des diffusen GI können wir weniger als einen diffusen GI-Strahl pro Bildschirmpixel emittieren. Dies kann durch Verringern der Pufferskala erfolgen (indem Sie den Schieberegler in den Optionen in der oberen rechten Ecke des Bildschirms bewegen).
Wenn die Pufferskala beispielsweise 0,5 beträgt, wird nur ein Strahl pro vier Bildschirmpixel emittiert. Dies führt zu einer enormen Steigerung der Produktivität. Mit derselben Benutzeroberfläche in der oberen rechten Ecke des Bildschirms können Sie auch die Anzahl der Abtastwerte pro Pixel im Renderziel (SPP) und die Anzahl der Strahlreflexionen ändern.
Senden Sie einen Strahl aus
Um Strahlen in die Szene emittieren zu können, muss jede Ebenengeometrie ein Format haben, das der Raytracer im Shader verwenden kann. Die Wolfenstein-Ebene hat ein 64 × 64-Raster codiert, sodass es einfach genug ist, alle Daten in eine einzige 64 × 64-Textur zu codieren:
- Im roten Kanal der Texturfarbe werden alle Objekte in der entsprechenden Zelle x, y des Kartengitters codiert. Wenn der Wert des roten Kanals Null ist, befinden sich keine Objekte in der Zelle. Andernfalls wird er von einer Wand (Werte von 1 bis 64), einer Tür, einer Lichtquelle oder einer Kugel belegt, deren Schnittpunkt überprüft werden muss.
- Wenn eine Kugel eine ebene Gitterzelle belegt, werden der Radius und die relativen x- und y- Koordinaten der Kugel innerhalb der Gitterzelle mit Grün-, Blau- und Alphakanal codiert.
Ein Strahl wird in einer Szene durch Durchlaufen einer Textur mit dem folgenden Code emittiert:
bool worldHit(n vec3 ro,in vec3 rd,in float t_min, in float t_max, inout vec3 recPos, inout vec3 recNormal, inout vec3 recColor) { vec3 pos = floor(ro); vec3 ri = 1.0/rd; vec3 rs = sign(rd); vec3 dis = (pos-ro + 0.5 + rs*0.5) * ri; for( int i=0; i<MAXSTEPS; i++ ) { vec3 mm = step(dis.xyz, dis.zyx); dis += mm * rs * ri; pos += mm * rs; vec4 mapType = texture2D(_MapTexture, pos.xz * (1. / 64.)); if (isWall(mapType)) { ... return true; } } return false; }
Ein ähnlicher Mesh Ray Tracing Code ist in diesem
Wolfenstein Shader auf Shadertoy zu finden.
Nach der Berechnung des Schnittpunkts mit der Wand oder Tür (unter Verwendung
des Schnittpunkttests mit einem Parallelogramm ) erhalten wir durch Suchen in demselben Texturatlas, der zum Übergeben des proaktiven Renderings verwendet wurde, Albedo-Schnittpunkte. Kugeln haben eine Farbe, die prozedural anhand ihrer
x-, y- Koordinaten im Raster und
der Farbverlaufsfunktion bestimmt wird .
Türen sind etwas komplizierter, weil sie sich bewegen. Damit die Szenendarstellung in der CPU (die zum Rendern von Netzen im Vorwärts-Rendering-Durchgang verwendet wird) mit der Szenendarstellung in der GPU (zur Raytracing-Funktion) übereinstimmt, bewegen sich alle Türen automatisch und deterministisch, basierend auf dem Abstand von der Kamera zur Tür.
Diffuse gi
Die gestreute globale Beleuchtung (diffuses GI) wird berechnet, indem Strahlen im Shader emittiert werden, mit denen die gesamte Geometrie im diffusen GI-Renderziel gezeichnet wird. Die Richtung dieser Strahlen hängt von der Normalen zur Oberfläche ab, die durch Abtasten der kosinusgewichteten Halbkugel bestimmt wird.
Mit der Strahlrichtung
rd und dem Startpunkt
ro kann die reflektierte Beleuchtung unter Verwendung des folgenden Zyklus berechnet werden:
vec3 getBounceCol(in vec3 ro, in vec3 rd, in vec3 col) { vec3 emitted = vec3(0); vec3 recPos, recNormal, recColor; for (int i=0; i<MAX_RECURSION; i++) { if (worldHit(ro, rd, 0.001, 20., recPos, recNormal, recColor)) {
Um das Rauschen zu reduzieren, wird der Schleife eine direkte Lichtabtastung hinzugefügt. Dies ähnelt der Technik, die in meinem
weiteren Cornell Box- Shader auf Shadertoy verwendet wird.
Reflexion
Dank der Möglichkeit, die Szene mit Strahlen im Shader zu verfolgen, ist es sehr einfach, Reflexionen hinzuzufügen. In meiner Demo werden Reflexionen hinzugefügt, indem dieselbe
oben gezeigte
getBounceCol- Methode mit dem reflektierten Strahl der Kamera
aufgerufen wird:
#ifdef REFLECTION col = mix(col, getReflectionCol(ro, reflect(normalize(vWorldPos - _CamPos), normal), albedo), .15); #endif
Reflexionen werden im Vorwärts-Rendering-Durchgang hinzugefügt, daher sendet ein Reflexionsstrahl immer einen Reflexionsstrahl aus.
Zeitliches Anti-Aliasing
Da sowohl weiche Schatten im Vorwärts-Rendering-Durchgang als auch die diffuse GI-Näherung ungefähr eine Abtastung pro Pixel verwenden, ist das Endergebnis extrem verrauscht. Um das Rauschen zu reduzieren, wurde temporäres Anti-Aliasing (TAA) verwendet, das auf
Playdeads TAA:
Temporal Reprojection Anti-Aliasing in INSIDE basiert.
Neuprojektion
Die Idee hinter TAA ist ganz einfach: TAA berechnet ein Subpixel pro Frame und mittelt dann seine Werte mit dem korrelierenden Pixel aus dem vorherigen Frame.
Um zu wissen, wo sich das aktuelle Pixel im vorherigen Frame befand, wird die Fragmentposition unter Verwendung der Modellansicht-Projektionsmatrix des vorherigen Frames neu projiziert.
Lassen Sie Proben fallen und begrenzen Sie Nachbarschaften
In einigen Fällen ist ein aus der Vergangenheit gespeichertes Beispiel ungültig, z. B. wenn sich die Kamera so bewegt hat, dass ein Fragment des aktuellen Bilds im vorherigen Bild durch Geometrie geschlossen wurde. Um solche ungültigen Proben zu verwerfen, wird eine Nachbarschaftsbeschränkung verwendet. Ich habe die einfachste Art der Einschränkung gewählt:
vec3 history = texture2D(_History, uvOld ).rgb; for (float x = -1.; x <= 1.; x+=1.) { for (float y = -1.; y <= 1.; y+=1.) { vec3 n = texture2D(_New, vUV + vec2(x,y) / _Resolution).rgb; mx = max(n, mx); mn = min(n, mn); } } vec3 history_clamped = clamp(history, mn, mx);
Ich habe auch versucht, die Restriktionsmethode basierend auf dem Begrenzungsparallelogramm zu verwenden, sah jedoch keinen großen Unterschied zu meiner Lösung. Dies geschah wahrscheinlich, weil es in der Szene aus der Demo viele identische dunkle Farben und fast keine sich bewegenden Objekte gibt.
Kameravibrationen
Um ein Anti-Aliasing zu erhalten, schwingt die Kamera in jedem Bild aufgrund der Verwendung einer (Pseudo-) zufälligen Subpixelverschiebung. Dies wird durch Ändern der Projektionsmatrix implementiert:
this._projectionMatrix[2 * 4 + 0] += (this.getHaltonSequence(frame % 51, 2) - .5) / renderWidth; this._projectionMatrix[2 * 4 + 1] += (this.getHaltonSequence(frame % 41, 3) - .5) / renderHeight;
Der Lärm
Rauschen ist die Grundlage der Algorithmen zur Berechnung des diffusen GI und der weichen Schatten. Die Verwendung von
gutem Rauschen wirkt sich stark auf die Bildqualität aus, während schlechtes Rauschen Artefakte erzeugt oder die Bildkonvergenz verlangsamt.
Ich befürchte, dass das in dieser Demo verwendete weiße Rauschen nicht sehr gut ist.
Die Verwendung von gutem Rauschen ist wahrscheinlich der wichtigste Aspekt bei der Verbesserung der Bildqualität in dieser Demo. Sie können beispielsweise
blaues Rauschen verwenden .
Ich habe Experimente mit Rauschen basierend auf dem Goldenen Schnitt durchgeführt, aber sie waren erfolglos. Bisher wird der berüchtigte
Hash ohne Sinus von Dave Hoskins verwendet:
vec2 hash2() { vec3 p3 = fract(vec3(g_seed += 0.1) * HASHSCALE3); p3 += dot(p3, p3.yzx + 19.19); return fract((p3.xx+p3.yz)*p3.zy); }
Geräuschreduzierung
Selbst wenn TAA aktiviert ist, zeigt die Demo immer noch viel Rauschen. Es ist besonders schwierig, die Decke zu rendern, da sie nur durch indirekte Beleuchtung beleuchtet wird. Dies vereinfacht nicht die Situation, dass die Decke eine große flache Oberfläche ist, die mit einer Volltonfarbe gefüllt ist: Wenn sie Textur oder geometrische Details hätte, würde das Geräusch weniger wahrnehmbar werden.
Ich wollte nicht viel Zeit mit diesem Teil der Demo verbringen, deshalb habe ich versucht, nur einen Rauschunterdrückungsfilter anzuwenden: Median3x3 von
Morgan McGuire und Kyle Witson . Leider funktioniert dieser Filter nicht sehr gut mit "Pixel Art" -Grafiken für Wandtexturen: Er entfernt alle Details in der Ferne und rundet die Ecken der Pixel benachbarter Wände ab.
In einem anderen Experiment habe ich denselben Filter auf das Diffuse GI Render Target angewendet. Obwohl er das Rauschen leicht reduzierte, gleichzeitig fast ohne die Details der Wandtexturen zu verändern, entschied ich, dass diese Verbesserung die zusätzlichen Millisekunden nicht wert war.
Demo
Sie können die Demo hier spielen .