GPU Lightmapper: Ein technischer Tiefflug

Das Beleuchtungsteam setzt voll auf die Iterationsgeschwindigkeit. Wir haben den Progressive Lightmapper mit diesem Ziel vor Augen entwickelt. Unser Ziel ist es, Ihnen schnelles Feedback zu allen Änderungen zu geben, die Sie an der Beleuchtung in Ihrem Projekt vornehmen. In 2018.3 haben wir eine Vorschau auf die GPU-Version des Progressiven Lightmappers eingeführt. Jetzt nähern wir uns der Gleichheit der Funktionen und der visuellen Qualität mit seinem CPU-Geschwisterchen. Unser Ziel ist es, die GPU-Version um eine Größenordnung schneller zu machen als die CPU-Version. Dies bringt interaktives Lightmapping in künstlerische Arbeitsabläufe, was die Produktivität des Teams erheblich verbessert.
Aus diesem Grund haben wir uns für die Verwendung von RadeonRays entschieden: eine Open-Source-Raytracing-Bibliothek von AMD. Unity und AMD haben beim GPU Lightmapper zusammengearbeitet, um mehrere wichtige Funktionen und Optimierungen zu implementieren. Nämlich: Power Sampling, Strahlenverdichtung und benutzerdefiniertes BVH-Traversal.
Das Designziel des GPU-Lightmappers war es, die gleichen Funktionen wie der CPU-Lightmapper zu bieten und gleichzeitig eine höhere Leistung zu erzielen:
- Unvoreingenommenes interaktives Lightmapping
- Funktionsgleichheit zwischen CPU- und GPU-Backends
- Compute-basierte Lösung
- Wellenfrontpfadverfolgung für maximale Leistung
Wir wissen, dass die Zeit der Iteration der Schlüssel zur Verbesserung der visuellen Qualität und zur Entfaltung der Kreativität ist. Interaktives Lightmapping ist hier das Ziel. Wir wollen nicht nur beeindruckende Gesamtbackzeiten, sondern auch, dass die Benutzererfahrung ein unmittelbares Feedback bietet.
Um dies zu erreichen, mussten wir eine Reihe von interessanten Problemen lösen. In diesem Beitrag werden wir einige der Entscheidungen, die wir getroffen haben, erläutern.
Damit der Lightmapper dem Benutzer progressive Aktualisierungen bieten kann, mussten wir einige Designentscheidungen treffen.
Bei direkter Beleuchtung werden Bestrahlungsstärke und Sichtbarkeit nicht zwischengespeichert (direkte Beleuchtung kann zwischengespeichert und für indirekte Beleuchtung wiederverwendet werden). Im Allgemeinen zwischenspeichern wir keine Daten und bevorzugen Berechnungsschritte, die klein genug sind, um keine Verzögerungen zu verursachen, und die während des Backens eine progressive und interaktive Anzeige bieten.

Szenen können unter Umständen sehr groß sein und viele Lichtkarten enthalten. Um sicherzustellen, dass die Arbeit dort eingesetzt wird, wo sie den größten Nutzen für den Nutzer bringt, ist es wichtig, sich beim Backen auf den aktuell sichtbaren Bereich zu konzentrieren. Dazu wird zunächst festgestellt, welche der Lichtkarten die meisten nicht konvergierten sichtbaren Texel auf einem Bildschirm enthalten, dann werden diese Lichtkarten gerendert und die sichtbaren Texel priorisiert (die Texel außerhalb des Bildschirms werden gebacken, sobald alle sichtbaren konvergiert sind).
Ein Texel ist als sichtbar definiert, wenn es sich im aktuellen Kamerastumpf befindet und nicht von statischen Geometrien der Szene verdeckt wird.
Wir führen dieses Culling auf der GPU durch (um die Vorteile des schnellen Raytracing zu nutzen). Hier ist der Ablauf eines Culling-Auftrags.

Die Culling-Aufträge haben zwei Ausgaben:
- Ein Culling-Map-Puffer, der speichert, ob jedes Texel der Lightmap sichtbar ist. Dieser Culling-Map-Puffer wird dann von den Rendering-Aufträgen verwendet.
- Eine ganze Zahl, die die Anzahl der sichtbaren Texel für die aktuelle Lightmap angibt. Diese Ganzzahl wird asynchron von der CPU zurückgelesen, um die Lichtkartenplanung in der Zukunft anzupassen.
Im folgenden Video sehen wir die Auswirkungen der Ausmerzung. Der Backvorgang wird zu Demonstrationszwecken auf halber Strecke abgebrochen. Wenn sich also die Szenenansicht bewegt, können wir noch nicht gebackene Texel (d. h. schwarze) sehen, die von der ursprünglichen Kameraposition und -richtung aus nicht sichtbar sind.
Aus Leistungsgründen werden die Sichtbarkeitsinformationen nur dann aktualisiert, wenn sich der Kamerazustand "stabilisiert". Außerdem wird das Supersampling nicht berücksichtigt.
Grafikprozessoren sind dafür optimiert, große Datenmengen zu verarbeiten und denselben Vorgang für alle Daten auszuführen; sie sind auf Durchsatz optimiert. Darüber hinaus erreicht die GPU diese Beschleunigung, während sie gleichzeitig energie- und kosteneffizienter ist als eine Many-Core-CPU. Allerdings sind GPUs in Bezug auf die Latenzzeit nicht so gut wie CPUs (absichtlich, aufgrund des Designs der Hardware). Deshalb verwenden wir eine datengesteuerte Pipeline ohne CPU-GPU-Synchronisationspunkte, um die inhärent parallele Berechnungsweise der GPU optimal zu nutzen.
Die reine Leistung ist jedoch nicht genug. Was zählt, ist das Nutzererlebnis, und das messen wir an der visuellen Wirkung im Laufe der Zeit, auch Konvergenzrate genannt. Wir brauchen also auch effiziente Algorithmen.
GPUs sind für große Datensätze gedacht und können einen hohen Durchsatz auf Kosten von Latenzzeiten erzielen. Außerdem werden sie in der Regel durch eine Warteschlange von Befehlen gesteuert, die von der CPU im Voraus gefüllt werden. Das Ziel dieses kontinuierlichen Stroms großer Befehle ist es, die GPU mit Arbeit zu sättigen. Schauen wir uns die wichtigsten Rezepte an, die wir zur Maximierung des Durchsatzes und damit der Rohleistung verwenden.
Unsere Herangehensweise an die GPU-Lightmapping-Datenpipeline basiert auf den folgenden Grundsätzen:
1. Wir bereiten die Daten einmalig auf.
Zu diesem Zeitpunkt könnten CPU und GPU synchronisiert sein, um die Speicherzuweisung zu reduzieren.
2. Sobald der Backvorgang begonnen hat, sind keine CPU-GPU-Synchronisierungspunkte mehr zulässig.
Die CPU sendet eine vordefinierte Arbeitslast an die GPU. Diese Arbeitslast wird in einigen Fällen zu konservativ sein (z.B. bei 4 Bounces, aber alle indirekten Strahlen sind nach dem 2. Bounce fertig, dann haben wir immer noch Kernel in der Warteschlange, die ausgeführt werden, aber zu früh raus).
3. Die GPU kann weder Strahlen noch Kerne erzeugen.
Vielmehr könnte er gebeten werden, leere (oder sehr kleine) Aufträge zu bearbeiten. Um diese Fälle effizient zu behandeln, werden Kernel so geschrieben, dass die Daten- und Befehlskohärenz maximiert wird. Wir handhaben dies über die "Verdichtung" von Daten, dazu später mehr.
4. Wir wollen weder CPU-GPU-Synchronisationspunkte noch irgendeine Art von GPU-Bubbles, sobald der Backvorgang begonnen hat.
Zum Beispiel können einige OpenCL-Befehle wie clEnqueueFillBuffer oder clEnqueueReadBuffer (sogar in den asynchronen Versionen) kleine GPU-Blasen erzeugen (d.h. Momente, in denen die GPU nichts zu verarbeiten hat), so dass wir diese so weit wie möglich vermeiden. Außerdem muss die Datenverarbeitung so lange wie möglich auf der GPU verbleiben (d. h. Rendering und Compositing bis zur Fertigstellung). Wenn wir Daten zur weiteren Verarbeitung zurück zur CPU bringen müssen, werden wir dies asynchron tun und sie auch nicht wieder an die GPU zurückschicken. So ist beispielsweise das Nähen von Nähten derzeit ein CPU-Postprozess.
5. Die CPU passt die GPU-Last asynchron an.
Das Ändern der gerenderten Lightmap, wenn sich die Kameraperspektive ändert oder wenn eine Lightmap vollständig konvergiert ist, führt zu einer gewissen Latenz. CPU-Threads erzeugen und verarbeiten diese Rückleseereignisse über eine sperrfreie Warteschlange, um Mutex-Konflikte zu vermeiden.

Eines der wichtigsten Merkmale der GPU-Architektur ist die breite Unterstützung von SIMD-Befehlen. SIMD steht für Single Instruction Multiple Data. Eine Reihe von Anweisungen wird sequentiell im Gleichschritt auf einer bestimmten Datenmenge innerhalb einer so genannten Warp/Wellenfront ausgeführt. Die Größe dieser Wellenfronten/Warps beträgt 64, 32 oder 16 Werte (je nach GPU-Architektur). Daher wird eine einzige Anweisung die gleiche Transformation auf mehrere Daten anwenden - eine Anweisung mehrere Daten. Für eine größere Flexibilität ist die GPU jedoch in der Lage, in ihrer SIMD-Implementierung auch abweichende Codepfade zu unterstützen. Zu diesem Zweck kann es einige Threads deaktivieren, während es an einer Teilmenge arbeitet, bevor es sich wieder anschließt. Dies wird als SIMT bezeichnet: Einzelne Anweisung, mehrere Threads. Dies hat jedoch seinen Preis, da abweichende Codepfade innerhalb einer Wellenfront/eines Warps nur von einem Bruchteil der SIMD-Einheit profitieren. Lesen Sie diesen hervorragenden Blogbeitrag für weitere Informationen.
Eine nette Erweiterung der SIMT-Idee ist schließlich die Fähigkeit der GPU, viele Warps/Wellenfronten pro SIMD-Kern zu speichern. Wenn eine Wellenfront/ein Warp auf einen langsamen Speicherzugriff wartet, kann der Scheduler zu einer anderen Wellenfront/einem anderen Warp wechseln und in der Zwischenzeit an dieser/ diesem weiterarbeiten (vorausgesetzt, es gibt genügend anstehende Arbeit). Damit dies jedoch wirklich funktioniert, muss die Menge der pro Kontext benötigten Ressourcen gering sein, damit die Auslastung (die Menge der anstehenden Arbeit) hoch sein kann.
Zusammenfassend lässt sich sagen, dass wir ein Ziel verfolgen sollten:
- Viele Fäden im Flug
- Vermeidung von abweichenden Zweigen
- Gute Auslastung
Bei einer guten Belegung dreht sich alles um den Kernel-Code und das Thema ist zu umfangreich, um es in diesem Blogbeitrag zu behandeln. Hier sind einige großartige Ressourcen:
- Vasily Volkov (NVIDIA) über das Verstecken von Latenzzeiten auf GPUs
- Einführung in die GPU-Skalarisierung von Francesco Cifariello (Unity Technologies)
Im Allgemeinen ist es das Ziel, lokale Ressourcen sparsam zu verwenden, insbesondere Vektorregister und lokalen gemeinsamen Speicher.
Schauen wir uns an, was der Fluss für das Backen direkter Beleuchtung auf der GPU sein könnte. In diesem Abschnitt geht es hauptsächlich um Lichtkarten, aber Lichtsonden funktionieren ganz ähnlich, mit dem Unterschied, dass sie keine Sichtbarkeits- oder Belegungsdaten haben.

Hier gibt es ein paar Probleme:
- Die Belegung der Lichtkarte beträgt in diesem Beispiel 44 % (4 belegte Texel auf 9), so dass nur 44 % der GPU-Threads tatsächlich brauchbare Arbeit leisten! Hinzu kommt, dass nützliche Daten nur spärlich im Speicher vorhanden sind, so dass wir auch für nicht belegte Texel Bandbreite bezahlen müssen. In der Praxis liegt die Auslastung der Lichtkarten in der Regel zwischen 50 % und 70 %, was einen enormen Gewinn bedeutet.
- Der Datensatz ist zu klein. Das Beispiel zeigt der Einfachheit halber eine 3x3-Lichtkarte, aber selbst der übliche Fall einer 512x512-Lichtkarte ist für aktuelle Grafikprozessoren ein zu kleiner Datensatz, um höchste Effizienz zu erreichen.
- In einem früheren Abschnitt haben wir über die Priorisierung von Ansichten und den Culling-Auftrag gesprochen. Die beiden obigen Punkte sind sogar noch zutreffender, da einige belegte Texel nicht gebacken werden, weil sie in der Szenenansicht derzeit nicht sichtbar sind, was die Belegung und den Gesamtdatensatz noch weiter verringert.
Wie können wir das lösen? Im Rahmen einer Zusammenarbeit mit AMD wurde die Strahlenverdichtung hinzugefügt. Diese Idee verbessert sowohl die Raytracing- als auch die Shading-Leistung erheblich. Kurz gesagt, die Idee ist, alle Strahlendefinitionen in einem zusammenhängenden Speicher zu erstellen, so dass alle Threads in einer Warp/Wellenfront an heißen Daten arbeiten können.
In der Praxis muss jeder Strahl auch den Index des Texels kennen, auf den er sich bezieht; wir speichern dies in der Nutzlast des Strahls. Außerdem speichern wir die Anzahl der verdichteten Strahlen.
Hier ist der Fluss mit Verdichtung:

Die beiden Kernel, die für die Schattierung und die Verfolgung der Strahlen zuständig sind, können jetzt nur noch mit heißem Speicher und mit minimalen Abweichungen in den Codepfaden laufen.
Was kommt als Nächstes? Nun, wir haben die Tatsache nicht gelöst, dass der Datensatz zu klein für die GPU sein könnte, insbesondere wenn die Ansichtspriorisierung aktiviert ist. Die nächste Idee besteht darin, die Erzeugung von Strahlen aus der gbuffer-Darstellung zu dekorrelieren. Bei dem naiven Ansatz wird nur ein Strahl pro Texel erzeugt. Da wir später ohnehin mehr Strahlen erzeugen wollen, können wir auch gleich mehrere Strahlen pro Texel erzeugen. Auf diese Weise können wir eine sinnvollere Arbeit für die GPU schaffen, an der sie zu knabbern hat. Hier ist der Ablauf:

Vor der Verdichtung erzeugen wir viele Strahlen pro Texel und nennen dies Expansion. Wir generieren auch Metainformationen, die im Erfassungsschritt verwendet werden, um sie im richtigen Zieltexel zu akkumulieren.
Sowohl der Expansions- als auch der Sammelkern werden nicht sehr oft ausgeführt. In der Praxis expandieren wir und schattieren dann jedes Licht (bei direktem Licht) oder verarbeiten alle Bounces (bei indirektem Licht), um schließlich nur ein einziges Licht zu erfassen.
Mit diesen Techniken erreichen wir unser Ziel: Wir generieren genug Arbeit, um den Grafikprozessor zu sättigen, und wir verwenden die Bandbreite nur für Texel, die wichtig sind.
Dies sind die Vorteile der Aufnahme mehrerer Strahlen pro Texel:
- Die Menge der aktiven Strahlen wird auch im Modus der Ansichtspriorisierung immer eine große Datenmenge sein.
- Vorbereitung, Verfolgung und Schattierung arbeiten alle mit sehr kohärenten Daten, da der Expansionskernel Strahlen erzeugt, die auf dasselbe Texel im kontinuierlichen Speicher abzielen.
- Der Erweiterungskern verwaltet die Belegung und Sichtbarkeit, wodurch der Vorbereitungskern viel einfacher und damit schneller wird.
- Die Größe der Puffer für den erweiterten/arbeitenden Datensatz ist von der Größe der Lichtkarte entkoppelt.
- Die Anzahl der Strahlen, die wir pro Texel schießen, kann durch einen beliebigen Algorithmus gesteuert werden, eine natürliche Erweiterung ist das adaptive Sampling.
Die indirekte Beleuchtung beruht auf sehr ähnlichen, wenn auch komplexeren Ideen:

Bei indirektem Licht müssen wir mehrere Bounces durchführen, von denen jeder einzelne zufällige Strahlen verwerfen kann. Daher führen wir die Verdichtung iterativ durch, um weiterhin mit heißen Daten zu arbeiten.
Die Heuristik, die wir derzeit verwenden, bevorzugt eine gleiche Anzahl von Strahlen pro Texel. Ziel ist es, einen sehr progressiven Output zu erhalten. Eine natürliche Erweiterung wäre es jedoch, diese Heuristiken durch adaptive Stichproben zu verbessern, um mehr Strahlen zu schießen, wenn die aktuellen Ergebnisse verrauscht sind. Außerdem könnte die Heuristik eine größere Kohärenz anstreben, sowohl im Speicher als auch bei der Ausführung von Thread-Gruppen, indem sie die Wellenfront/Warp-Größe der Hardware berücksichtigt.
Assets aus ArchVizPRO, gebacken mit GPU Lightmapper.
Es gibt viele Anwendungsfälle für Transparenz/Transluzenz. Eine gängige Methode zur Behandlung von Transparenz und Durchsichtigkeit besteht darin, einen Strahl zu erzeugen, eine Überschneidung zu erkennen, das Material zu holen und einen neuen Strahl zu planen, wenn das angetroffene Material durchsichtig oder transparent ist. In unserem Fall kann die GPU jedoch aus Leistungsgründen keine Strahlen erzeugen (siehe den Abschnitt "Datengesteuerte Pipeline" weiter oben). Außerdem können wir von der CPU nicht verlangen, genügend Strahlen im Voraus einzuplanen, damit wir sicher sein können, dass wir den schlimmsten Fall bewältigen, da dies einen großen Leistungsverlust bedeuten würde.
Deshalb haben wir uns für eine Hybridlösung entschieden. Wir behandeln Transluzenz und Transparenz unterschiedlich und können so die oben genannten Probleme lösen:
Transparenz (wenn ein Material nicht undurchsichtig ist, weil es Löcher hat). In diesem Fall kann der Strahl auf der Grundlage einer Wahrscheinlichkeitsverteilung entweder durch das Material hindurchgehen oder an ihm abprallen. Die von der CPU im Voraus vorbereitete Arbeitslast braucht sich also nicht zu ändern, wir sind immer noch szenenunabhängig.
Lichtdurchlässigkeit (wenn ein Material das Licht, das durch es hindurchgeht, filtert). In diesem Fall wird eine Annäherung vorgenommen und die Brechung nicht berücksichtigt. Mit anderen Worten: Wir lassen das Material das Licht färben, aber nicht seine Richtung ändern. Dadurch können wir die Transluzenz beim Durchlaufen des BVH handhaben, d. h. wir können problemlos eine große Anzahl von Ausschnittmaterialien handhaben und sehr gut mit der Komplexität der Transluzenz in der Szene skalieren.

Allerdings gibt es eine Besonderheit: Das BVH-Traversal ist nicht in Ordnung:
Im Falle von Verdeckungsstrahlen ist dies eigentlich in Ordnung, da wir nur an der Abschwächung durch die Durchsichtigkeit jedes durchschnittenen Dreiecks entlang des Strahls interessiert sind. Da die Multiplikation kommutativ ist, ist eine BVH-Traversierung außer der Reihe kein Problem.
Bei sich schneidenden Strahlen wollen wir jedoch in der Lage sein, auf einem Dreieck zu stoppen (auf wahrscheinliche Weise, wenn das Dreieck transparent ist) und die Transluzenzabschwächung für jedes Dreieck vom Strahlenursprung bis zum Trefferpunkt zu sammeln. Da das BVH-Traversal nicht in Ordnung ist, haben wir uns dafür entschieden, zunächst nur den Schnittpunkt auszuführen, um den Trefferpunkt zu finden, und den Strahl zu markieren, wenn eine Transluzenz getroffen wurde. Für jeden markierten Strahl wird also ein zusätzlicher Verdeckungsstrahl vom Ursprung des Schnittstrahls bis zum Treffer des Schnittstrahls erzeugt. Um dies effizient zu tun, verwenden wir die Verdichtung bei der Erzeugung der Okklusionsstrahlen, d.h. man zahlt nur dann die zusätzlichen Kosten, wenn der Schnittstrahl als transluzent markiert wurde.
All das war dank der Open-Source-Natur von RadeonRays möglich, das im Rahmen der Zusammenarbeit mit AMD geforkt und an unsere Bedürfnisse angepasst wurde.
Wir haben gesehen, was wir in Bezug auf die rohe Leistung tun, großartig! Dies ist jedoch nur der erste Teil des Puzzles. Hohe Abtastraten pro Sekunde sind großartig, aber was letztendlich wirklich zählt, ist die Backzeit. Mit anderen Worten, , wir wollen das Maximum aus jedem Strahl herausholen, den wir werfen. Diese letzte Aussage ist das Ergebnis jahrzehntelanger Forschung. Hier sind einige großartige Ressourcen:
Raytracing an einem Wochenende
Ray Tracing: Die nächste Woche
Ray Tracing: Der Rest deines Lebens
Unity GPU Lightmapper ist ein reiner Diffuslichtmapper. Dies vereinfacht die Interaktion des Lichts mit den Materialien erheblich und hilft auch, Glühwürmchen und Lärm zu dämpfen. Es gibt jedoch noch eine Menge zu tun, um die Konvergenzrate zu verbessern. Hier sind einige der Techniken, die wir verwenden:
Russisches Roulette
Bei jedem Abprall wird der Pfad auf der Grundlage der akkumulierten Albedo probabilistisch gelöscht. Eine gute Erklärung findet man in Eric Veachs Dissertation (Seite 67).
Umwelt Multiple Importance Sampling (MIS)
HDR-Umgebungen, die eine hohe Varianz aufweisen, können ein beträchtliches Maß an Rauschen in der Ausgabe verursachen, was eine große Anzahl von Abtastungen erfordert, um ansprechende Ergebnisse zu erzielen. Daher wenden wir eine Kombination von Probenahmestrategien an, die speziell auf die Bewertung des Umfelds zugeschnitten sind, indem wir es zunächst analysieren, wichtige Bereiche identifizieren und dann entsprechende Proben nehmen. Dieser Ansatz, der nicht nur für Umweltstichproben gilt, ist allgemein als "multiple importance sampling" bekannt und wurde ursprünglich in der Dissertation von Eric Veach vorgeschlagen (Seite 252). Dies geschah in Zusammenarbeit mit Unity Labs Grenoble.
Viele Lichter
Bei jedem Bounce wählen wir ein direktes Licht aus und begrenzen die Anzahl der Lichter, die auf Oberflächen mit einer räumlichen Gitterstruktur einwirken, mit Wahrscheinlichkeit. Dies geschah in Zusammenarbeit mit AMD. Wir untersuchen derzeit das Problem der vielen Lichter, da die Auswahl der Lichter für die Qualität entscheidend ist.

Rauschunterdrückung
Das Rauschen wird mit einem AI-Denoiser entfernt, der auf die Ausgaben eines Path Tracers trainiert wurde. Sehen Sie sich Jesper Mortensens Unity-Präsentation auf der GDC 2019 an.
Wir haben gesehen, wie eine datengesteuerte Pipeline, das Augenmerk auf rohe Leistung und effiziente Algorithmen miteinander kombiniert werden, um ein interaktives Lightmapping-Erlebnis mit dem GPU Lightmapper zu bieten. Bitte beachten Sie, dass sich der GPU Lightmapper in aktiver Entwicklung befindet und ständig verbessert wird.
Teilen Sie uns Ihre Meinung mit!
Das Beleuchtungsteam
PS: Wenn Ihnen das hier gefallen hat und Sie Interesse an einer neuen Herausforderung haben, suchen wir derzeit einen Lighting Developer (m/w) in Kopenhagen, also melden Sie sich!
---
Möchten Sie lernen, wie man Grafiken in Unity optimiert? Sehen Sie sich dieses Tutorial an .
