Jeder der folgenden Abschnitte beschreibt eine Situation, die sich auf die Performance Ihrer Anwendung auswirken kann, und bietet Richtlinien für alternative Ansätze und Problemumgehungen, die Ihnen bei der Lösung von Problemen helfen können, die möglicherweise auftreten können.
Bevor Sie fortfahren
Falls Sie nicht mit Performance-Profilen in Unreal Engine vertraut sind, empfehlen wir Ihnen ausdrücklich die Lektüre von Introduction to Performance Profiling and Configuration (Einführung in Performanceprofile und -konfiguration), um grundlegende Kenntnisse zu diesem Thema zu erlangen, bevor Sie mit der Lektüre der folgenden Abschnitte fortfahren.
Verwaltete Objekte, Garbage Collection und Verarbeitungsspitzen
In Unreal Engine werden UObjects und sämtliche von ihnen abgeleiteten Klassen (wie Actors und Daten-Assets) durch den Garbage Collector der Engine verwaltet. Der Garbage Collector bereinigt regelmäßig UObjects, die aus der Welt gelöscht werden, und entfernt alle bestehenden Referenzen auf diese Objekte.
Im Vergleich dazu sind Standard-Objekte in C++ nicht verwaltet. Das heißt, dass Sie nach dem Löschen oder Nullen einer Kopie eines Objekts die Referenzen darauf manuell bereinigen müssen. Das birgt ein Risiko, wenn es nicht sorgfältig gehandhabt wird, da jede Lücke in der Logik zu Speicherlecks (wenn Objekte nicht bereinigt werden) und ungültigen Referenzen (wenn Objekte gelöscht werden, aber Referenzen bestehen bleiben) führen kann.
Die Unterstützung für verwaltete Objekte führt zu zusätzlichem Speicherverbrauch. UObjects enthalten zusätzliche Metadaten wie einen FName
und eine Outer
-Referenz, die zusätzlichen Speicher verbrauchen. Der Garbage Collector muss von Zeit zu Zeit laufen, um Objekte automatisch zu bereinigen. Daher muss ein Back-End-System in der Lage sein, alle Referenzen von Objekten zu überwachen. Verarbeitungsspitzen treten oft in Frames auf, wenn der Garbage Collector ausgeführt wird, insbesondere wenn Ihre Anwendung in letzter Zeit eine große Anzahl an Objekten zerstört hat.
Sie können die Garbage Collection unter Projekt-Einstellungen > Engine > Garbage Collection konfigurieren, einschließlich des Intervalls der Garbage Collection, der maximalen Anzahl von Objekten, die zu einem bestimmten Zeitpunkt bereinigt werden können, und anderer Einstellungen für den Ablauf der Garbage Collection. Sie werden wahrscheinlich in der Frühphase Ihres Projekts keine Feinabstimmungen vornehmen müssen, allerdings erhalten Sie so Möglichkeiten, das Verhalten des Garbage Collector von Unreal Engine an die einzigartigen Bedürfnisse Ihres Projekts anzupassen.
Wir empfehlen Ihnen, sich auf die automatische Garbage Collection zu verlassen. Bei Bedarf können Sie den Garbage Collector auch manuell mit dem Collect Garbage-Knoten in Blueprints oder der Funktion UObjectGlobals::CollectGarbage
in C++ aufrufen.
Das führt zu Verarbeitungsspitzen. Es kann aber Situationen geben, in denen ein manueller Aufruf der Garbage Collection verhindert, dass sich ungültige Daten im Hintergrund anhäufen, was zu höheren Spitzen führt, wenn sie später automatisch ausgeführt wird.
Die manuelle Garbage Collection ist in folgenden Situationen sinnvoll:
Wenn das Programm sich in einem Zustand befindet, in dem aus UX-Perspektive Framespitzen eher tolerierbar sind, etwa während eines Ladebildschirms. Dies verringert die Wahrscheinlichkeit des Auftretens in einem Zustand, in dem es auffälliger oder untragbar wäre.
Vor einer Operation, die viel Speicher allokiert, wenn Sie beim Testen feststellen, dass die Operation Abstürze bei zu wenig Arbeitsspeicher oder Aussetzer beim Seitentausch verursachen kann, wenn die Garbage Collection nicht unmittelbar zuvor durchgeführt wird.
Erstellen und Zerstören von Objekten und Objekt-Pooling
Um ein Objekt zu erstellen, muss Ihr Computer einen neuen Speicherblock allokieren, um eine Kopie des Objekts zu speichern, und diesen anschließend zusammen mit allen benötigten Unterobjekten initialisieren. Wenn Sie ein Objekt zerstören, müssen Sie diese Informationen löschen, die Allokation aufheben und alle Referenzen auf dieses Objekt löschen, die möglicherweise an anderer Stelle im Code Ihrer Anwendung existieren.
Beide Operationen können ziemlich teuer sein, insbesondere wenn ihre Initialisierung die Koordination mit anderen Systemen umfasst. Meistens ist Unreal Engine effizient genug bei der Handhabung dieser Operationen, dass Sie sie in vielen Kontexten auf PC und Konsolen sicher einsetzen können, aber in Projekten mit einem begrenzten Spielraum für die Verarbeitung auf der CPU möchten Sie vielleicht stattdessen Objekt-Pooling nutzen. Objekt-Pooling ist der Vorgang, bei dem alle benötigten Kopien eines Objekts im Voraus erstellt, im Speicher allokiert und dann deaktiviert oder verborgen werden, bis sie benötigt werden.
Je hochstufiger ein Objekt ist, desto teurer wird die Erstellung und Zerstörung. Pooling ist eher für Actors als für Komponenten nützlich und ist eher für Komponenten als für andere UObjects nützlich. Der Grund dafür ist, dass die Kosten für die Erstellung eines Actor auch darin bestehen, ihn in die Actor-Liste der Welt einzufügen, seine Komponenten zu erstellen und ihn und seine Komponenten bei zusätzlicher Infrastruktur wie Rendering und Physik zu registrieren. Bei C++-Strukturen, die bei Erstellung und Zerstörung nicht mit zusätzlichen Klassen interagieren, kann ein Pooling-Versuch weniger effizient sein, als dem System-Allokator zu erlauben, seinen Rohspeicher zu recyceln.
Denken Sie als Beispiel an eine Waffe, die Projektile verschießt. Es ist üblich, dass eine Waffe beim Abfeuern ein Projektil spawnt, das sich selbst zerstört, wenn es mit einem anderen Objekt kollidiert.
Mit Objekt-Pooling spawnt Ihre Waffe nicht jedes Mal ein neues Projektil, wenn Sie eines abfeuern müssen, sondern hat bereits die maximale Anzahl an Projektilen, die zu einem bestimmten Zeitpunkt aktiv sein können, vorab gespawnt und verbirgt und deaktiviert sie dann. Diese Gruppe deaktivierter Projektile ist der Objektpool. Wenn Ihre Waffe ein Projektil abfeuert, nimmt sie das Projektil aus dem Pool, bewegt es an das Ende der Waffe, zeigt es an, aktiviert es und initialisiert es in die richtige Richtung. Wenn das Projektil dann ein Ziel trifft, wird es ausgeblendet, deaktivieren sich und kehrt in den Pool zurück, um später erneut verwendet zu werden.
Der Vorteil von Objekt-Pools ist, dass Sie keine Objekte erstellen oder zerstören müssen, was viel Verarbeitungszeit für ihre Initialisierung und Bereinigung spart. Der Nachteil ist, dass sie Speicher verbrauchen, der sonst nicht belegt wäre, selbst wenn Objekte im Pool inaktiv sind. In vielen Situationen müssen Sie jedoch Platz für die maximale Anzahl an Objekten lassen, die der Pool sowieso benötigt. Außerdem bleibt der Speicher für diese Objekte stabiler, da Sie ihn in großen Brocken statt in kleineren Brocken allokieren und bereinigen, was die Möglichkeit einer Speicherfragmentierung verringert.
Bei-Tick-Logik und Callbacks, Timer und geplante Logik
Das Tick-Event in tickbaren UObjects und Actors bietet eine Möglichkeit, Logik zu erstellen, die sich in jedem Frame wiederholt. Dies ist praktisch für die Handhabung von Echtzeitbewegungen. Allerdings kann die Verwendung von Tick für gelegentliche und nicht kontinuierliche Routinen zu verschwendeter CPU-Auslastung führen.
Insbesondere ist es oft nicht optimal, eine Logik zu verwenden, die prüft, ob sich eine Variable in jedem Frame geändert hat, wie im folgenden Beispiel. Eine Klasse verwendet Tick, um wiederholt zu prüfen, wann sich die Variable einer anderen Klasse ändert.
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AChildActor* ChildActor;
protected:
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
int32 getMyInt(){
return MyInt;
}
private:
int32 myInt = 0;
Anstatt den Wert mit Tick zu überwachen, können Sie eine benutzerdefinierte Setter-Funktion erstellen, die den Vorgang zum Ändern der Variablen umschließt, und dann eine andere Funktion oder einen Event aufrufen, der die benötigte Logik nur dann ausführt, wenn Sie den Wert ändern.
Das folgende Beispiel umfasst die Klassen aus dem letzten Beispiel, verwendet nun aber einen Callback, um eine Aktion auszuführen, wenn sich eine Variable geändert hat:
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
void OnChildIntChanged(int32 NewValue)
{
if (newValue > 3)
{
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AMyActor* ParentActor;
Auf diese Weise stellen Sie sicher, dass die Logik nur ausgeführt wird, wenn sich Ihre Variable ändert, anstatt in jedem Frame einen Wert abzufragen.
Allerdings kann der event-gesteuerte Ansatz weniger optimal sein, je nachdem, wie oft sich die Bedingung ändert. Wenn ein Event mehrfach pro Frame ausgelöst wird oder wenn eine Funktion an viele Events angehängt ist, die sich alle im selben Frame ändern können, kann es effizienter sein, Tick oder das „Befehlsmuster“ zu verwenden. So werden Berechnungsergebnisse vermieden, die vor dem Rendern überschrieben werden.
Wenn Sie ein Event so planen möchten, dass es nach einer festgelegten Zeit stattfindet, können Sie einen Timer starten, der kurzzeitig die verstrichene Zeit bis zum Abschluss nachverfolgt und sich dann selbst bereinigt. Alternativ können Sie den Delay-Knoten in einem Blueprints-Event-Diagramm verwenden.
Wenn Sie eine Logik benötigen, die sich häufig, aber nicht in jedem Frame wiederholen muss, sollten Sie sie in bestimmten Frame- oder Sekundenintervallen ausführen lassen. Sie können dies in einzelnen Objekten und Actor-Komponenten tun, indem Sie deren Tick-Intervall auf eine bestimmte Anzahl von Sekunden festlegen. Alternativ können Sie Intervalle für Teilmengen der Logik in Ihrer Tick-Funktion erstellen. Sie müssen dafür zwar trotzdem eine Variable sammeln und zurücksetzen, aber es ist immer noch billiger, als die Logik bei jedem Frame auszuführen.
Asynchrone und synchrone Logik
Synchrone Logik bezieht sich auf den Abschluss von Aktionen nacheinander von Anfang bis Ende. Ein Großteil der Logik, die Sie in Blueprints oder C++ schreiben, ist standardmäßig synchron. Wenn Sie beispielsweise ein Event in Blueprints erstellen, aber keine Delay-Knoten, Timer oder Gameplay-Aufgaben hinzufügen, wird die gesamte Logik, die von diesem Blueprint-Event ausgeht, gleichzeitig im selben Frame ausgeführt. Der Frame kann die Verarbeitung nicht abschließen, bis diese Logik vollständig ausgeführt ist. Wenn Sie umfangreiche Operationen ausführen, insbesondere bei großen Datensätzen oder großen Objekten, die aus dem Speicher geladen oder entladen werden müssen, kann das zu erheblichen Verarbeitungsspitzen führen.
Die asynchrone Logik bezieht sich auf den parallelen Abschluss von Aktionen, entweder buchstäblich zur gleichen Zeit (auf separaten CPU-Kernen) oder logisch zur gleichen Zeit (verschachtelt in kleinere Brocken, die technisch gesehen synchron auf niedriger Ebene ausgeführt werden). Eine asynchrone Operation wird ausgeführt, bis sie abgeschlossen ist, während das Hauptprogramm weiterläuft, ohne darauf zu warten, dass die Operation aufholen kann. Normalerweise verwenden asynchrone Operationen Callbacks, um ihren Abschluss zu signalisieren.
Mehrere Frameworks innerhalb von Unreal Engine, darunter das World-Partition-System und verschiedene Systeme zur Bereitstellung von Inhalt auf Anfrage, sind bereits asynchron. Sie sollten für Ihre Projekte eine asynchrone Logik implementieren, um die Operationen über einen bestimmten Zeitraum zu verteilen, um zu vermeiden, dass eine einzelne Operation oder ein Frame zu viel Gewichtung hat.
Zum Beispiel müssen Sie vielleicht eine große Anzahl Gegner, beispielsweise 30 oder mehr, in einem wellen-basierten Verteidigungsspiel laden und instanziieren. Da es ohnehin teuer ist, einen neuen Actor in der Laufzeit zu erstellen, ist es sehr umständlich, alle im selben Frame zu verarbeiten. Stattdessen könnten Sie eine asynchrone Operation erstellen, die nur bis zu 5 Gegner pro Frame spawnt, bis das angegebene Limit erreicht ist oder alle angegebenen Spawn-Positionen erschöpft sind. Das würde dazu führen, dass alle 30 Gegner im Verlauf von 6 Frames spawnen. An diesem Punkt würden Sie signalisieren, dass der Massen-spawn-Vorgang abgeschlossen ist. Dies erleichtert die Arbeitslast, so viele Gegner spawnen zu lassen, und die meisten Spieler werden gar nicht bemerken, wie lange das Spawnen dauert, da es über ein Zehntel oder ein Fünftel einer Sekunde passiert.
Parallele Verarbeitung in Unreal Engine
Die parallele Verarbeitung ist ein Typ der asynchronen Verarbeitung, bei der Aktionen auf demselben Computer, aber in verschiedenen Threads oder CPU-Kernen abgearbeitet werden. Einige Beispiele für parallele Verarbeitung in Unreal Engine umfassen:
Auflösung von Soft-Pointern.
Laden von Leveln und Assets im Hintergrund.
Asynchrones Laden von Assets aus einem Online-Inhaltslieferungssystem.
Ein Thread ist ein dedizierter Pfad für die Verarbeitung von Anweisungen, entweder auf der CPU oder der GPU. Die meisten CPUs haben mehrere Kerne, die ihrerseits individuelle Prozessoren sind, und jeder Kern kann mehrere Threads haben. Die Nutzung der parallelen Verarbeitung ist entscheidend, um sicherzustellen, dass ein Programm keinen Engpass für CPU-Prozesse darstellt, insbesondere wenn es mehr Aufgaben und größere Datenmengen verarbeitet.
Beachtenswerte Verarbeitungs-Threads
Unreal Engine hat dedizierte Threads für Folgendes:
Threadname | Beschreibung |
---|---|
Spiel | Verarbeitet UObject- und Actor-Logik in C++ und Blueprints sowie Benutzeroberfläche-Logik. Die meisten Programmierungsschritte werden in diesem Thread stattfinden. |
Rendering | Konvertiert die Szenenstruktur in Zeichenbefehle |
RHI | Sendet Zeichen-Befehle an die GPU |
Aufgabenpools | Verarbeitet verschiedene Aufgaben in wiederverwendbaren Threads |
Ton | Verarbeitung für Sound und Musik |
Laden | Verarbeitet das Laden und Entladen von Daten |
Sie finden diese Threads im Fenster Timing Insights von Unreal Insights.
Da der Spiel-Thread sehr viel Logik verarbeitet, ist es wichtig, Ihren Code sorgfältig zu profilieren und zu optimieren.
Erstellen Ihrer eigenen Thread-Logik
UE verfügt über mehrere Ressourcen zum Hinzufügen Ihrer eigenen parallelen Verarbeitungslogik:
Das Aufgabensystem bietet ein stabiles und relativ leichtgewichtiges Framework zur Aufteilung von Logik in Aufgaben, die parallel in separaten Threads ausgeführt werden können.
FRunnable bietet das direkteste niedrigstufige Interface zur Ausführung einer Funktion in einem beliebigen Thread. Das sollte vermieden werden, es sei denn, Sie wissen, was Sie tun und haben eine Rechtfertigung dafür, einen dedizierten Thread anstelle des Thread-Pools zu verwenden.
Seien Sie vorsichtig bei der Erstellung Ihrer eigenen Thread-Logik, da dies zu „race“-Zuständen führen kann, bei denen Fehler auftreten, wenn Operationen in einer unerwarteten Reihenfolge ausgeführt werden.
Zusätzlich bietet die Seite Thread-Rendering Einblicke in die rendering-spezifische Thread-Logik.
Shader-Kompilierung, Framerate-Störungen und PSO-Caching
Unreal Engine kompiliert Materialanweisungen in Shaders, um sie für die Ausführung auf der GPU vorzubereiten. Auch wenn sich die Performance der Materialien nach Abschluss der Kompilierung deutlich verbessert, kann das Kompilieren der Shaders zu erheblichen Verarbeitungsspitzen führen, was wiederum zu momentanen, aber spürbaren Framerate-Störungen führt.
Um dem entgegenzuwirken, implementiert UE PSO-Caching. Sie können entweder PSOs manuell sammeln, indem Sie Ihre Anwendung spielen und testen, oder PSO Precaching verwenden, um sie automatisch zu generieren. In jedem Fall besteht der Gedanke darin, jeden möglichen Zustand aufzuzeichnen, den Ihre Grafikkarte rendern wird, wenn die Anwendung läuft, und diese Daten dann im Cache zu speichern und zu bündeln und sie für nachfolgende Builds zu verwenden. Dies verringert den Umfang der Shader-Kompilierung, die zur Laufzeit erfolgen muss, drastisch, da Sie den Großteil im Voraus laden können. Daher verringert sich die Anzahl der Störungen, die Benutzer beim Laden neuer Bereiche und Materialien erleben.