GPU Ray Tracing in Unity - Teil 3

[ Der erste und zweite Teil.]


Heute werden wir einen großen Sprung machen. Wir werden uns von ausschließlich sphärischen Strukturen und der unendlichen Ebene, die wir zuvor verfolgt haben, entfernen und Dreiecke hinzufügen - die ganze Essenz moderner Computergrafik, ein Element, aus dem alle virtuellen Welten bestehen. Wenn Sie mit dem fortfahren möchten, was wir das letzte Mal beendet haben, verwenden Sie den Code aus Teil 2 . Den fertigen Code für das, was wir heute tun werden, finden Sie hier . Fangen wir an!

Dreiecke


Ein Dreieck ist nur eine Liste von drei verbundenen Scheitelpunkten , von denen jeder seine eigene Position speichert und manchmal normal ist. Die Reihenfolge der Durchquerung der Eckpunkte aus Ihrer Sicht bestimmt, was wir betrachten - die Vorder- oder Rückseite des Dreiecks. Traditionell wird die „Front“ als Durchlaufreihenfolge gegen den Uhrzeigersinn betrachtet.

Zunächst müssen wir feststellen können, ob und an welchem ​​Punkt der Strahl ein Dreieck schneidet. Ein sehr beliebter (aber sicherlich nicht der einzige ) Algorithmus zur Bestimmung des Schnittpunkts eines Strahls mit einem Dreieck wurde 1997 von den Herren Thomas Akenin-Meller und Ben Trembor vorgeschlagen. Weitere Informationen finden Sie in ihrem Artikel „Schnelle, minimale Speicher-Ray-Triangle-Kreuzung“ hier .

Der Code aus dem Artikel kann einfach auf den HLSL-Shader-Code portiert werden:

static const float EPSILON = 1e-8; bool IntersectTriangle_MT97(Ray ray, float3 vert0, float3 vert1, float3 vert2, inout float t, inout float u, inout float v) { // find vectors for two edges sharing vert0 float3 edge1 = vert1 - vert0; float3 edge2 = vert2 - vert0; // begin calculating determinant - also used to calculate U parameter float3 pvec = cross(ray.direction, edge2); // if determinant is near zero, ray lies in plane of triangle float det = dot(edge1, pvec); // use backface culling if (det < EPSILON) return false; float inv_det = 1.0f / det; // calculate distance from vert0 to ray origin float3 tvec = ray.origin - vert0; // calculate U parameter and test bounds u = dot(tvec, pvec) * inv_det; if (u < 0.0 || u > 1.0f) return false; // prepare to test V parameter float3 qvec = cross(tvec, edge1); // calculate V parameter and test bounds v = dot(ray.direction, qvec) * inv_det; if (v < 0.0 || u + v > 1.0f) return false; // calculate t, ray intersects triangle t = dot(edge2, qvec) * inv_det; return true; } 

Um diese Funktion nutzen zu können, benötigen wir einen Strahl und drei Eckpunkte eines Dreiecks. Der Rückgabewert gibt an, ob sich das Dreieck geschnitten hat. Im Falle eines Schnittpunkts werden drei zusätzliche Werte berechnet: t beschreibt den Abstand entlang des Strahls zum Schnittpunkt, und u / v sind zwei der drei Schwerpunktkoordinaten, die die Position des Schnittpunkts auf dem Dreieck bestimmen (die letzte Koordinate kann als w = 1 - u - v berechnet werden). Wenn Sie noch nicht mit Schwerpunktkoordinaten vertraut sind, lesen Sie deren hervorragende Erklärung auf Scratchapixel .

Lassen Sie uns ohne allzu große Verzögerung ein Dreieck mit den im Code angegebenen Eckpunkten verfolgen! Suchen Sie die Trace Funktion im Shader und fügen Sie das folgende Codefragment hinzu:

 // Trace single triangle float3 v0 = float3(-150, 0, -150); float3 v1 = float3(150, 0, -150); float3 v2 = float3(0, 150 * sqrt(2), -150); float t, u, v; if (IntersectTriangle_MT97(ray, v0, v1, v2, t, u, v)) { if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(cross(v1 - v0, v2 - v0)); bestHit.albedo = 0.00f; bestHit.specular = 0.65f * float3(1, 0.4f, 0.2f); bestHit.smoothness = 0.9f; bestHit.emission = 0.0f; } } 

Wie gesagt, t speichert den Abstand entlang des Strahls, und wir können diesen Wert direkt verwenden, um den Schnittpunkt zu berechnen. Die Normale, die für die Berechnung der korrekten Reflexion wichtig ist, kann unter Verwendung des Vektorprodukts von zwei beliebigen Kanten des Dreiecks berechnet werden. Starten Sie den Spielemodus und bewundern Sie Ihr erstes nachgezeichnetes Dreieck:


Übung: Versuchen Sie, die Position anhand der Schwerpunktkoordinaten und nicht anhand der Entfernung zu berechnen. Wenn Sie alles richtig machen, sieht das glänzende Dreieck genauso aus wie zuvor.

Dreiecksnetze


Wir haben das erste Hindernis überwunden, aber das Verfolgen ganzer Maschen aus Dreiecken ist eine ganz andere Geschichte. Zuerst müssen wir einige grundlegende Informationen über Netze lernen. Wenn Sie sie kennen, können Sie den nächsten Absatz sicher überspringen.

In der Computergrafik wird das Netz durch mehrere Puffer definiert, von denen die wichtigsten die Scheitelpunkt- und Indexpuffer sind. Der Scheitelpunktpuffer ist eine Liste von 3D-Vektoren, die die Position jedes Scheitelpunkts im Objektraum beschreiben (dies bedeutet, dass solche Werte beim Verschieben, Drehen oder Skalieren eines Objekts nicht geändert werden müssen - sie werden mithilfe der Matrixmultiplikation im laufenden Betrieb vom Objektraum in den Weltraum konvertiert). . Ein Indexpuffer ist eine Liste von Ganzzahlwerten, bei denen es sich um Indizes handelt , die auf den Scheitelpunktpuffer verweisen. Alle drei Indizes bilden ein Dreieck. Wenn der Indexpuffer beispielsweise die Form [0, 1, 2, 0, 2, 3] hat, hat er zwei Dreiecke: Das erste Dreieck besteht aus dem ersten, zweiten und dritten Scheitelpunkt im Scheitelpunktpuffer und das zweite Dreieck besteht aus dem ersten, dritten und vierte Spitzen. Daher bestimmt der Indexpuffer auch die oben erwähnte Durchlaufreihenfolge. Zusätzlich zu Scheitelpunktpuffern und -indizes können zusätzliche Puffer vorhanden sein, die jedem Scheitelpunkt andere Informationen hinzufügen. Die gebräuchlichsten zusätzlichen Puffer speichern Normalen , Texturkoordinaten ( Texcoords oder einfach UV genannt ) sowie Scheitelpunktfarben .

Verwenden von GameObjects


Zunächst müssen wir herausfinden, welche GameObjects Teil des Raytracing-Prozesses werden sollen. Eine naive Lösung wäre, einfach FindObjectOfType<MeshRenderer>() , aber etwas flexibleres und schnelleres zu tun. RayTracingObject wir eine neue RayTracingObject Komponente hinzu:

 using UnityEngine; [RequireComponent(typeof(MeshRenderer))] [RequireComponent(typeof(MeshFilter))] public class RayTracingObject : MonoBehaviour { private void OnEnable() { RayTracingMaster.RegisterObject(this); } private void OnDisable() { RayTracingMaster.UnregisterObject(this); } } 

Diese Komponente wird jedem Objekt hinzugefügt, das wir für die Raytracing-Funktion verwenden möchten, und wird mit RayTracingMaster . Fügen Sie dem Assistenten die folgenden Funktionen hinzu:

 private static bool _meshObjectsNeedRebuilding = false; private static List<RayTracingObject> _rayTracingObjects = new List<RayTracingObject>(); public static void RegisterObject(RayTracingObject obj) { _rayTracingObjects.Add(obj); _meshObjectsNeedRebuilding = true; } public static void UnregisterObject(RayTracingObject obj) { _rayTracingObjects.Remove(obj); _meshObjectsNeedRebuilding = true; } 

Alles läuft gut - jetzt wissen wir, welche Objekte verfolgt werden müssen. Aber der knifflige Teil geht weiter: Wir werden alle Daten aus Unity-Netzen (Matrix, Vertex-Puffer und Indizes - erinnern Sie sich an sie?) Sammeln, sie in unsere eigenen Datenstrukturen schreiben und in die GPU laden, damit der Shader sie verwenden kann. Beginnen wir mit der Definition von Datenstrukturen und Puffern auf der C # -Seite im Assistenten:

 struct MeshObject { public Matrix4x4 localToWorldMatrix; public int indices_offset; public int indices_count; } private static List<MeshObject> _meshObjects = new List<MeshObject>(); private static List<Vector3> _vertices = new List<Vector3>(); private static List<int> _indices = new List<int>(); private ComputeBuffer _meshObjectBuffer; private ComputeBuffer _vertexBuffer; private ComputeBuffer _indexBuffer; 

... und jetzt machen wir dasselbe im Shader. Bist du daran gewöhnt?

 struct MeshObject { float4x4 localToWorldMatrix; int indices_offset; int indices_count; }; StructuredBuffer<MeshObject> _MeshObjects; StructuredBuffer<float3> _Vertices; StructuredBuffer<int> _Indices; 

Datenstrukturen sind fertig und wir können sie mit realen Daten füllen. Wir sammeln alle Eckpunkte aller Netze in einer großen List<Vector3> und alle Indizes in einer großen List<int> . Es gibt keine Probleme mit den Scheitelpunkten, aber die Indizes müssen geändert werden, damit sie weiterhin auf den richtigen Scheitelpunkt in unserem großen Puffer zeigen. Stellen Sie sich vor, wir haben bereits Objekte aus 1000 Eckpunkten hinzugefügt und fügen jetzt einen einfachen Netzwürfel hinzu. Das erste Dreieck kann aus Indizes [0, 1, 2] bestehen. Da wir jedoch bereits 1000 Scheitelpunkte im Puffer hatten, müssen wir die Indizes verschieben, bevor wir dem Würfel Scheitelpunkte hinzufügen. Das heißt, sie werden zu [1000, 1001, 1002]. So sieht es im Code aus:

 private void RebuildMeshObjectBuffers() { if (!_meshObjectsNeedRebuilding) { return; } _meshObjectsNeedRebuilding = false; _currentSample = 0; // Clear all lists _meshObjects.Clear(); _vertices.Clear(); _indices.Clear(); // Loop over all objects and gather their data foreach (RayTracingObject obj in _rayTracingObjects) { Mesh mesh = obj.GetComponent<MeshFilter>().sharedMesh; // Add vertex data int firstVertex = _vertices.Count; _vertices.AddRange(mesh.vertices); // Add index data - if the vertex buffer wasn't empty before, the // indices need to be offset int firstIndex = _indices.Count; var indices = mesh.GetIndices(0); _indices.AddRange(indices.Select(index => index + firstVertex)); // Add the object itself _meshObjects.Add(new MeshObject() { localToWorldMatrix = obj.transform.localToWorldMatrix, indices_offset = firstIndex, indices_count = indices.Length }); } CreateComputeBuffer(ref _meshObjectBuffer, _meshObjects, 72); CreateComputeBuffer(ref _vertexBuffer, _vertices, 12); CreateComputeBuffer(ref _indexBuffer, _indices, 4); } 

Wir rufen RebuildMeshObjectBuffers in der OnRenderImage Funktion auf und vergessen nicht, neue Puffer in OnDisable . Hier sind zwei Hilfsfunktionen, die ich im obigen Code verwendet habe, um die Pufferbehandlung ein wenig zu vereinfachen:

 private static void CreateComputeBuffer<T>(ref ComputeBuffer buffer, List<T> data, int stride) where T : struct { // Do we already have a compute buffer? if (buffer != null) { // If no data or buffer doesn't match the given criteria, release it if (data.Count == 0 || buffer.count != data.Count || buffer.stride != stride) { buffer.Release(); buffer = null; } } if (data.Count != 0) { // If the buffer has been released or wasn't there to // begin with, create it if (buffer == null) { buffer = new ComputeBuffer(data.Count, stride); } // Set data on the buffer buffer.SetData(data); } } private void SetComputeBuffer(string name, ComputeBuffer buffer) { if (buffer != null) { RayTracingShader.SetBuffer(0, name, buffer); } } 

Großartig, wir haben Puffer erstellt und sie sind mit den notwendigen Daten gefüllt! Jetzt müssen wir dies nur noch dem Shader melden. Fügen Sie SetShaderParameters den folgenden Code SetShaderParameters (und dank neuer SetShaderParameters können wir den Code des SetShaderParameters reduzieren):

 SetComputeBuffer("_Spheres", _sphereBuffer); SetComputeBuffer("_MeshObjects", _meshObjectBuffer); SetComputeBuffer("_Vertices", _vertexBuffer); SetComputeBuffer("_Indices", _indexBuffer); 

Die Arbeit ist also langweilig, aber lassen Sie uns sehen, was wir gerade getan haben: Wir haben alle internen Daten der Netze (Matrix, Eckpunkte und Indizes) gesammelt, sie in eine bequeme und einfache Struktur gebracht und sie dann an die GPU gesendet, auf die wir uns jetzt freuen Sie können verwendet werden.

Netzverfolgung


Lassen wir ihn nicht warten. Im Shader haben wir bereits den Trace-Code eines einzelnen Dreiecks, und das Netz besteht tatsächlich nur aus vielen Dreiecken. Der einzige neue Aspekt hierbei ist, dass wir die Matrix verwenden, um die Scheitelpunkte vom Objektraum in den Weltraum mithilfe der integrierten mul Funktion (kurz für Multiplizieren) zu transformieren. Die Matrix enthält die Translation, Rotation und Skalierung des Objekts. Es hat eine Größe von 4 × 4, daher benötigen wir für die Multiplikation einen 4d-Vektor. Die ersten drei Komponenten (x, y, z) werden aus dem Scheitelpunktpuffer entnommen. Wir setzen die vierte Komponente (w) auf 1, weil es sich um einen Punkt handelt. Wenn dies die Richtung wäre, würden wir 0 darin schreiben, um alle Übersetzungen und Skalierungen in der Matrix zu ignorieren. Ist das verwirrend für dich? Dann lesen Sie dieses Tutorial mindestens acht Mal. Hier ist der Shader-Code:

 void IntersectMeshObject(Ray ray, inout RayHit bestHit, MeshObject meshObject) { uint offset = meshObject.indices_offset; uint count = offset + meshObject.indices_count; for (uint i = offset; i < count; i += 3) { float3 v0 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i]], 1))).xyz; float3 v1 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 1]], 1))).xyz; float3 v2 = (mul(meshObject.localToWorldMatrix, float4(_Vertices[_Indices[i + 2]], 1))).xyz; float t, u, v; if (IntersectTriangle_MT97(ray, v0, v1, v2, t, u, v)) { if (t > 0 && t < bestHit.distance) { bestHit.distance = t; bestHit.position = ray.origin + t * ray.direction; bestHit.normal = normalize(cross(v1 - v0, v2 - v0)); bestHit.albedo = 0.0f; bestHit.specular = 0.65f; bestHit.smoothness = 0.99f; bestHit.emission = 0.0f; } } } } 

Wir sind nur einen Schritt davon entfernt, alles in Aktion zu sehen. Lassen Sie uns die Trace Funktion ein wenig umstrukturieren und eine Spur von Netzobjekten hinzufügen:

 RayHit Trace(Ray ray) { RayHit bestHit = CreateRayHit(); uint count, stride, i; // Trace ground plane IntersectGroundPlane(ray, bestHit); // Trace spheres _Spheres.GetDimensions(count, stride); for (i = 0; i < count; i++) { IntersectSphere(ray, bestHit, _Spheres[i]); } // Trace mesh objects _MeshObjects.GetDimensions(count, stride); for (i = 0; i < count; i++) { IntersectMeshObject(ray, bestHit, _MeshObjects[i]); } return bestHit; } 

Ergebnisse


Das ist alles! RayTracingObject wir einige einfache Netze hinzu (Unity- RayTracingObject sind in Ordnung), geben Sie ihnen die RayTracingObject Komponente und beobachten Sie die Magie. Verwenden Sie noch keine detaillierten Netze (mehr als ein paar hundert Dreiecke)! Unser Shader ist nicht optimiert, und wenn Sie es übertreiben, kann es Sekunden oder sogar Minuten dauern, bis mindestens ein Sample pro Pixel verfolgt wird. Infolgedessen stoppt das System den GPU-Treiber, die Unity-Engine kann abstürzen und der Computer muss neu gestartet werden.


Beachten Sie, dass unsere Netze keine glatte, sondern flache Schattierung haben. Da wir die Normalen der Eckpunkte noch nicht in den Puffer geladen haben, müssen wir ein Vektorprodukt ausführen, um die Normalen der Eckpunkte jedes Dreiecks zu erhalten. Außerdem können wir nicht über den Bereich des Dreiecks interpolieren. Wir werden uns im nächsten Teil des Tutorials mit diesem Problem befassen.

Aus Gründen des Interesses habe ich Stanford Bunny aus dem Morgan McGwire-Archiv heruntergeladen und mit dem Dezimierungsmodifikator des Blender- Pakets die Anzahl der Scheitelpunkte auf 431 reduziert. In der IntersectMeshObject Shader-Funktion können Sie mit Beleuchtungsparametern und fest codiertem Material experimentieren. Hier ist ein dielektrisches Kaninchen mit schönen weichen Schatten und ein wenig diffuser globaler Beleuchtung im Grafitti Shelter :


... und hier ist ein Metallkaninchen unter dem starken Richtungslicht von Cape Hill , das Disco-Blendung auf die Bodenebene wirft:


... und hier sind zwei kleine Kaninchen, die sich unter dem großen Stein Suzanne unter dem blauen Himmel verstecken. Kiara 9 Dusk (Ich habe für das zweite Objekt alternatives Material verschrieben und überprüft, ob die Indexverschiebung Null ist):


Was weiter?


Es ist großartig, zum ersten Mal ein echtes Netz in Ihrem eigenen Tracer zu sehen, oder? Heute haben wir einige Daten verarbeitet, mithilfe des Meller-Trambor-Algorithmus Informationen über die Kreuzung erhalten und alles gesammelt, damit wir die GameObjects-Engine der Unity-Engine sofort verwenden können. Darüber hinaus haben wir einen der Vorteile der Raytracing-Funktion erkannt: Sobald Sie dem Code einen neuen Schnittpunkt hinzufügen, funktionieren alle schönen Effekte (weiche Schatten, reflektierte und diffuse globale Beleuchtung usw.) sofort.

Das Rendern eines glänzenden Kaninchens nahm viel Zeit in Anspruch, und ich musste immer noch ein wenig filtern, um das offensichtlichste Geräusch zu beseitigen. Um dieses Problem zu lösen, wird eine Szene normalerweise in einer räumlichen Struktur geschrieben, beispielsweise in einem Raster, einem K-dimensionalen Baum oder einer Hierarchie von Begrenzungsvolumina, was die Geschwindigkeit beim Rendern großer Szenen erheblich erhöht.

Aber wir müssen uns in der richtigen Reihenfolge bewegen: Außerdem werden wir das Problem mit Normalen beseitigen, damit unsere Netze (auch solche mit niedrigem Polygehalt) glatter aussehen als jetzt. Es wäre auch schön, Matrizen beim Verschieben von Objekten automatisch zu aktualisieren und direkt auf Unity-Materialien zu verweisen und sie nicht nur in den Code zu schreiben. Dies werden wir im nächsten Teil der Tutorial-Reihe tun. Danke fürs Lesen und wir sehen uns in Teil 4!

Source: https://habr.com/ru/post/de450308/


All Articles