Spielengines und Shader-Stottern: Die Lösung der Unreal Engine für dieses Problem
Hallo zusammen, wir sind Kenzo ter Elst, Daniele Vettorel, Allan Bentham und Mihnea Balta, wir gehören zu den Ingenieuren, die am PSO-Precaching-System der Unreal Engine gearbeitet haben.
Vor Kurzem gab es eine Reihe von Gesprächen in der Epic-Community, bei denen es um Shader-Stottern und dessen Auswirkungen auf Spielentwicklungsprojekte ging.
Heute sprechen wir darüber, warum das Phänomen auftritt, erklären, wie PSO-Precaching dabei helfen kann, das Problem zu lösen, und untersuchen einige Best Practices für die Entwicklung, mit denen du Shader-Stottern minimierst. Wir teilen außerdem unsere Pläne für die Zukunft des PSO-Precaching-Systems.
Wenn du mehr erfahren möchtest, solltest du am Donnerstag, den 6. Februar, um 20:00 Uhr MEZ unseren Inside Unreal-Livestream auf Twitch und YouTube nicht verpassen.
Stottern bei der Shader-Kompilierung tritt auf, wenn eine Render-Engine entdeckt, dass sie einen neuen Shader kompilieren muss, bevor sie diesen zum Abrufen verwendet, also hält alles kurz an, während der Treiber das Kompilieren abschließt. Wenn wir verstehen wollen, wie das passieren kann, müssen wir uns genauer ansehen, wie Shader in Code umgewandelt werden, der auf der GPU läuft.
Shader sind Programme, die auf der GPU ausgeführt werden, um die verschiedenen Schritte auszuführen, die für das Rendering von 3D-Bildern erforderlich sind: Transformation, Deformation, Beschattung, Beleuchtung, Nachbearbeitung und so weiter. Diese sind üblicherweise in einer High-Level-Programmiersprache wie beispielsweise HLSL geschrieben, die zu Maschinencode kompiliert werden muss, den eine GPU ausführen kann. Dieser Prozess ist bei CPUs ähnlich, wo in einer High-Level-Programmiersprache wie C++ geschriebener Code in einen Compiler gefüttert wird, um Befehle für eine bestimmte Architektur zu erstellen: x64, ARM usw.
Allerdings gibt es einen wichtigen Unterschied: Jede Plattform (PC, Mac, Android usw.) verwendet normalerweise ein oder zwei Befehls-Sets für CPUs, aber viele verschiedene GPUs mit völlig unterschiedlichen Befehls-Sets. Eine ausführbare Datei, die vor zehn Jahren für x64-PCs kompiliert wurden, kann auf heute von AMD und Intel produzierten Chips laufen, weil beide Hersteller die gleichen Befehls-Sets nutzen und umfangreiche Abwärtskompatibilität garantieren. Im Gegensatz dazu funktioniert eine GPU-Binärdatei, die für AMD kompiliert wurde, nicht bei NVIDIA und umgekehrt, und die Befehls-Sets können sich sogar zwischen verschiedenen Hardware-Generationen des gleichen Herstellers unterscheiden.
Darum ist es zwar möglich, CPU-Programme direkt in ausführbaren Maschinencode zu kompilieren und diesen zu verteilen, bei GPU-Programmen muss aber eine andere Herangehensweise genutzt werden. High-Level-Shader-Code wird zu einer Zwischen-Repräsentation kompiliert, die man Bytecode nennt, die ein abstraktes Befehls-Set verwendet, das von der 3D-API vorgegeben wird: DXBC für Direct3D 11, DXIL für Direct3D 12, SPIR-V für Vulkan usw.
Spiele liefern diese Bytecode-Binärdateien, damit sie eine einzelne Shader-Bibliothek haben, anstatt eine pro möglicher GPU-Architektur. Zur Laufzeit übersetzt der Treiber den Bytecode für die im Rechner eingebaute GPU ausführbaren Code. Diese Vorgehensweise wird manchmal auch für CPU-Programme verwendet – Java-Code beispielsweise wird in Bytecode kompiliert, damit die gleiche Binärdatei auf allen Plattformen ausgeführt werden kann, die über eine Java-Umgebung verfügen, unabhängig von der CPU.
Als dieses System eingeführt wurde, hatten Spiele relativ einfache und wenige Shader, also war die Umwandlung von Bytecode in ausführbaren Code recht unkompliziert, und folglich war die Rechenzeit-Anforderung, dies zur Laufzeit zu tun, eher unerheblich. Als GPUs leistungsfähiger wurden, gab es mehr und mehr Shader-Code, und die Treiber nutzten bald ausgefeilte Transformationen, um effizienteren Maschinencode zu produzieren, was bedeutete, das Laufzeit-Kompilierungskosten zu einem Problem wurden. Der Wendepunkt kam mit Direct3D 11, also versuchen moderne APIs wie Direct3D 12 und Vulkan, dies durch das Konzept der Pipeline-Zustandsobjekte (PSOs) zu lösen.
Rendering eines Objekts beinhaltet in der Regel mehrere Shader (z. B. einen Vertex-Shader und einen Pixel-Shader, die zusammenarbeiten) sowie eine Reihe von anderen Einstellungen für die GPU: Ausblenden-Modus, Überblendmodus, Tiefe und Schablonen-Vergleichsmodus usw. Gemeinsam beschreiben diese die Konfiguration oder den Zustand der GPU-Pipeline.
Ältere Grafik-APIs wie Direct3D 11 und OpenGL ermöglichen es, Teile dieses Zustands einzeln und zu beliebigen Zeiten zu ändern, was bedeutet, dass der Treiber nur die komplette Konfiguration sieht, wenn das Spiel eine Draw-Anfrage schickt. Bestimmte Einstellungen beeinflussen den ausführbaren Shader-Code, es gibt also Fälle, in denen der Treiber nur mit dem Kompilieren der Shader beginnen kann, wenn der Draw-Befehl ausgeführt wird. Dies kann für jede einzelne Draw-Anfrage Dutzende von Millisekunden oder mehr dauern, was zu sehr langen Frames führen kann, wenn ein Shader zum ersten Mal verwendet wird – ein Phänomen, das den meisten Gamern als Hitch oder Stottern bekannt ist.
Moderne APIs erfordern von Entwicklern, dass sie alle Shader und Einstellungen, die sie für eine Draw-Anfrage nutzen, in ein Pipeline-Zustandsobjekt (PSO) verpacken und als einzelne Einheit aufsetzen. Dabei ist wichtig, dass PSOs jederzeit konstruiert werden können. In der Theorie sind Engines also in der Lage, alles Nötige rechtzeitig zu erstellen (beispielsweise beim Laden), damit die Kompilierung vor dem Rendering genug Zeit hat, um fertig zu werden.
Die Unreal Engine hat ein leistungsstarkes Material-Authoring-System, mit dem Künstler grafisch beeindruckende und fesselnde Welten erschaffen, und viele Spiele enthalten Tausende von Materialien. Jedes davon kann viele verschiedene Shader produzieren – beispielsweise gibt es verschiedene Scheitelpunkt-Shader für das Rendering eines Materials auf statischen, geskinnten oder Spline-Meshs. Der gleiche Scheitelpunkt-Shader kann für mehrere Pixel-Shader verwendet werden, was erneut durch verschiedene Sets von Pipeline-Einstellungen multipliziert wird. Dies kann zu Millionen unterschiedlicher PSOs führen, die alle im Voraus kompiliert werden müssen, damit alle Möglichkeiten abgedeckt sind, was selbstverständlich sowohl aus Zeit- als auch aus Speichergründen nicht machbar ist – das Laden eines Levels würde Stunden dauern.
Ein sehr geringer Satz dieser möglichen PSOs wird zur Laufzeit verwendet, aber wir können nicht bestimmen, was dieser Satz beinhaltet, wenn wir uns ein Material isoliert ansehen. Der Satz kann sich auch zwischen den Spielsitzungen ändern: das Ändern von Video-Einstellungen schaltet bestimmte Rendering-Funktionen um, was die Engine dazu veranlasst, andere Shader- oder Pipeline-Zustände zu verwenden. Frühe Engine-Implementierungen von Direct3D 12 verließen sich auf Spieltests, automatisierte Level-Durchflüge und andere derartige Ermittlungsmethoden, um aufzuzeichnen, welche PSOs in der Praxis auftreten. Diese Daten wurden in das fertige Spiel aufgenommen und verwendet, um die bekannten PSOs beim Start oder beim Laden des Levels zu erstellen. Die Unreal Engine nennt das einen "Paket-PSO-Cache", was auch bis UE 5.2 unsere empfohlene Best Practice war.
Der Paket-Cache reicht für bestimmte Spiele aus, hat aber viele Einschränkungen. Diesen zu erstellen ist ressourcenintensiv, und er muss aktualisiert werden, wenn sich Inhalte ändern. Der Aufnahmeprozess entdeckt möglicherweise in Spielen mit besonders dynamischen Welten nicht alle erforderlichen PSOs, beispielsweise wenn sich die Materialien von Objekten durch Spieleraktionen ändern.
Der Cache kann wesentlich größer werden als für eine Spielsitzung erforderlich, wenn es zwischen Sitzungen viel Variation gibt, z. B. wenn es viele Karten gibt oder Spieler viele Skins zur Auswahl haben. Fortnite ist ein gutes Beispiel, bei dem ein Paket-Cache keine gute Option ist, da es genau diese Einschränkungen hat. Außerdem verfügt das Spiel über von Nutzern generierte Inhalte, also müsste es einen PSO-Cache pro Erfahrung nutzen, und die Inhaltsersteller müssten diese Caches sammeln.
Damit wir große, abwechslungsreiche Spielwelten und nutzergenerierte Inhalte unterstützen können, führte Unreal Engine 5.2 PSO-Precaching ein, eine Technik, mit der potenzielle PSOs beim Laden bestimmt werden. Wenn ein Objekt geladen wird, untersucht das System dessen Materialien und nutzt Informationen aus dem Mesh (z. B. statisch oder animiert) sowie den globalen Zustand (z. B. Videoqualitätseinstellungen), um einen Satz möglicher PSOs zu berechnen, die vermutlich zum Rendern des Objekts verwendet werden.
Dieser Satz ist immer noch größer als das, was schlussendlich verwendet wird, aber sehr viel kleiner als alle möglichen Kombinationen, also wird es möglich, beim Laden zu kompilieren. Fortnite Battle Royale beispielsweise kompiliert ungefähr 30.000 PSOs für ein Match und verwendet etwa 10.000 davon, aber selbst das stellt nur eine sehr kleine Menge der gesamten möglichen Kombinationen dar, die in die Millionen reichen.
Objekte, die während dem Laden der Karte erstellt werden, laden ihre PSOs in den Precache, während der Ladebildschirm angezeigt wird. Die Objekte, die während dem Gameplay reingestreamt oder gespawnt werden, können entweder darauf warten, dass ihre PSOs bereit sind, bevor sie gerendert werden, oder ein bereits kompiliertes Standard-Material verwenden. In den meisten Fällen verzögert dies das Streaming nur für ein paar Frames, was nicht auffällt. Dieses System hat PSO-Kompilierungsstottern für Materialien komplett eliminiert und funktioniert nahtlos mit nutzergenerierten Inhalten.
Das Ändern des Materials auf einem bereits sichtbaren Mesh ist schwieriger, da wir dieses nicht verstecken oder mit einem Standard-Material rendern wollen, während das neue PSO kompiliert wird. Wir arbeiten an einer API, die Spielcode and Blueprints ermöglichen, das System im Voraus zu informieren, damit die zusätzlichen PSOs ebenfalls in den Precache aufgenommen werden können. Wir wollen außerdem die Engine anpassen, damit diese das vorherige Material weiterhin rendert, während das neue kompiliert wird.
Die Unreal Engine hat eine separate Klasse von Shadern, die nichts mit Materialien zu tun haben. Diese nennt man globale Shader und sind Programme, die vom Renderer zur Implementierung verschiedener Algorithmen und Effekte verwendet wird, beispielsweise Bewegungsunschärfe, Hochskalieren, Entrauschen usw. Der Precaching-Mechanismus deckt auch global Berechnungs-Shader ab, aber ab UE 5.5 werden globale Grafik-Shader nicht mehr damit verarbeitet. Diese Arten von PSOs können selten einmalige Hitches verursachen, wenn sie zuerst verwendet werden. Wir arbeiten bereits daran, diese verbleibende Lücke in der Precaching-Abdeckung zu schließen.
Der Paket-Cache kann zusammen mit Precaching verwendet werden, was für bestimmte Spiele nützlich sein kann. Einige verbreitete Materialien können in den Paket-Cache aufgenommen werden, damit diese beim Start kompiliert werden, anstatt während dem Gameplay. Das kann auch mit globalen Grafik-Shadern helfen, da der Ermittlungsprozess diese findet und aufnimmt.
Treiber speichern kompilierte PSOs auf der Festplatte, damit diese direkt geladen werden können, wenn sie bei späteren Spielsitzungen erneut gebraucht werden. Dies hilft Spielen unabhängig von der verwendeten Engine und PSO-Kompilierungsstrategie. Bei Unreal Engine-Titeln, die PSO-Precaching verwenden, bedeutet dies, dass Ladebildschirme beim zweiten Durchlauf merklich kürzer sind. Fortnite lädt bei Battle Royale-Spielen 20 bis 30 Sekunden länger, wenn der Treiber-Cache leer ist. Der Cache wird geleert, wenn ein neuer Treiber installiert wird, also sind längere Ladezeiten normal, wenn das Spiel nach einem Treiber-Update das erste Mal wieder ausgeführt wird.
Die Unreal Engine nutzt den Treiber-Cache, indem sie PSOs beim Laden erstellt und diese sofort verwirft, wenn sie fertig kompiliert sind – darum nennt man die Technik Precaching. Wenn eine PSO später zum Rendering gebraucht wird, gibt die Engine eine Kompilierungsanfrage aus, aber der Treiber ruft sie einfach aus dem Cache ab, da das Precaching-System dafür gesorgt hat, dass diese dort gespeichert ist. Sobald eine PSO zur Darstellung genutzt wird, bleibt sie geladen, bis alle Primitives, die sie verwenden, aus der Szene entfernt werden, damit der Treiber nicht jeden Frame darum gebeten wird.
Verwerfen nach dem Precaching hat den Vorteil, das nicht gebrauchte PSOs nicht im Speicher bleiben. Der Nachteil ist, dass das Abrufen einer PSO aus dem Treiber-Cache etwas Zeit braucht, und auch wenn es viel schneller ist, als sie zu kompilieren, kann dies beim ersten Rendern eines Materials zu winzigen Rucklern führen.
Eine einfache Lösung ist das Behalten von pre-gecachten PSOs, anstatt diese zu verwerfen, aber das kann die Speicherverwendung um mehr als 1 GB erhöhen und sollte daher nur auf Rechnern getan werden, die genug RAM haben. Wir arbeiten an Lösungen, um den Speicherverbrauch zu verringern und automatisch zu entscheiden, wann die PSOs behalten werden können.
Nur bestimmte Zustände wirken sich auf den ausführbaren PSO-Code aus. Das bedeutet, wenn wir zwei PSOs mit den gleichen Shadern erschaffen, die aber andere Pipeline-Einstellungen haben, kann es sein, dass nur der erste durch den kostspieligen Kompilierungsprozess läuft und der zweite direkt aus dem Cache ausgegeben wird.
Leider unterscheiden sich die Zustands-Sets, die für Code-Generierung wichtig sind, zwischen den einzelnen GPUs und können sich sogar von einer Treiber-Version zur anderen ändern. Die Unreal Engine nutzt praktisches Wissen, das uns ermöglicht, einige Permutationen im Precaching-Prozess zu überspringen. Redundante Anfragen sind dank dem Treiber-Cache kürzer, aber die Engine muss dennoch arbeiten, um diese zu generieren. Diese Arbeit summiert sich, daher verringert der Entfernungsprozess Ladezeiten und Speicherverbrauch.
Mobilgerät-Plattformen nutzen das gleiche Shader-Kompilierungsmodell auf dem Gerät, und das Precaching-System der Unreal Engine ist auch hier effektiv. Allgemein verwenden Mobilgerät-Renderer weniger Shader als Desktop-Renderer, aber die PSO-Kompilierung dauert wesentlich länger, da die CPUs langsamer sind, also mussten wir einige Anpassungen am Prozess vornehmen, um es durchführbar zu machen.
Wir überspringen einige selten verwendete Permutationen, was bedeutet, dass der Precache nicht länger konservativ ist, daher können in bestimmten Fällen Hitches auftreten, wenn einer der ungewöhnlichen Zustände gerendert wird. Wir haben außerdem eine Zeitüberschreitung für Precaching während dem Laden der Karten eingerichtet, damit der Ladebildschirm nicht zu lange angezeigt wird. Dies bedeutet, dass das Spiel starten kann, obwohl noch Kompilierungsaufgaben zu erfüllen sind, was zu Stottern führen kann, wenn eines der noch zu bearbeitenden PSOs sofort gebraucht wird. Wir nutzen ein Prioritäts-Boostsystem, um Aufgaben an die Spitze der Warteschlange zu bewegen, wenn ein PSO gebraucht wird, um diese Hitches zu minimieren.
Konsolen müssen dieses Problem nicht lösen, da sie nur eine Ziel-GPU haben. Einzelne Shader werden direkt in ausführbaren Code kompiliert und mit dem Spiel ausgeliefert. Es gibt keine kombinatorische Explosion, wenn man den gleichen Vertex-Shader mit mehreren Pixel-Shadern verwendet oder bestimmte Pipeline-Zustände auftreten, da diese Faktoren keine Rekompilierung auslösen. Shader und Zustände können zur Laufzeit in PSOs zusammengesetzt werden, ohne dass merkliche Kosten anfallen, also treten auf diesen Plattformen keine PSO-Hitches auf.
Es gibt ein teilweises Missverständnis, dass es diese Probleme in Direct3D 11 nicht gab, und manchmal wird dazu aufgerufen, wieder zum alten Kompilierungsmodell oder sogar den alten Grafik-APIs zurückzukehren. Wie zuvor erklärt traten Hitches auch damals auf, und aufgrund der Konstruktion der API war es für Engines nicht möglich, diese zu verhindern. Sie traten weniger oft oder weniger lang auf, weil Spiele einfachere und weniger Shader hatten, und Funktionen wie Raytracing existierten gar nicht.
Treiber leisteten auch eine Menge Arbeit, um Stottern zu minimieren, konnten es aber nicht komplett vermeiden. Direct3D 12 versuchte, das Problem durch Einführen von PSOs zu lösen, bevor es schlimmer wurde, aber Engines brauchten eine Weile, diese effektiv zu verwenden, was zum Teil an der Schwierigkeit lag, bestehende Materialsysteme nachzurüsten, und teilweise an Schwächen der API, die erst erkennbar wurden, als Spiele komplexer wurden.
Die Unreal Engine ist eine Allzweck-Engine mit vielfältigen Einsatzmöglichkeiten und tonnenweise existierenden Inhalten und Workflows, also war das Problem besonders schwer zu lösen. Wir sind endlich an einem Punkt, an dem wir eine funktionierende Lösung haben, und es gibt außerdem gute Initiativen, um die Mängel der API zu beheben, zum Beispiel die Vulkan-Erweiterung Grafik-Pipeline-Bibliothek.
Das Precaching-System hat sich seit der ersten versuchsweisen Einführung in 5.2 stark entwickelt und verhindert die meisten Arten von Kompilierungsstottern. Allerdings gibt es noch gewisse Abdeckungslücken und andere Einschränkungen, also arbeiten wir weiter daran, es zu verbessern. Wir arbeiten auch mit Hardware- und Softwareherstellern daran, Treiber und Grafik-APIs daran anzupassen, wie Spiele diese Systeme in der Praxis verwenden.
Unser schlussendliches Ziel ist es, Precaching automatisch und optimal durchzuführen, damit Spielentwickler nichts tun müssen, um Hitches zu vermeiden. Bis das System fertig ist, können Lizenznehmer ein paar Schritte unternehmen, um flüssiges Gameplay sicherzustellen:
Denk dran, mehr zu diesem Thema gibt es am Donnerstag, den 6. Februar um 20:00 Uhr MEZ in unserem Inside Unreal-Stream auf Twitch und YouTube.
So installierst du die Unreal Engine
Den Launcher herunterladen
Bevor du den Unreal Editor installieren und ausführen kannst, musst du den Epic Games Launcher herunterladen und installieren.
Epic Games Launcher installieren
Öffne den Launcher nach dem Herunterladen und melde dich bei deinem Epic-Games-Konto an oder erstelle ein neues Konto.
Erhalte Unterstützung oder starte den Download deines Epic Games Launchers wie in Schritt 1 beschrieben neu.
Unreal Engine installieren
Sobald du eingeloggt bist, navigiere zum Reiter "Unreal Engine" und klicke auf die Schaltfläche "Installieren", um die neueste Version herunterzuladen.