Entwickeln
Entwickeln
Deine Plattform auswählen

Open-World-Spiele und Asset-Streaming

Aktualisiert: 09.12.2024
Eine der größten Herausforderungen bei der Entwicklung umfangreicher Open-World-Spiele besteht darin, diese Welt zur Laufzeit im Arbeitsspeicher unterzubringen. Die Welt, die du entwickelt hast, enthält zahlreiche Meshes, Materialien, Soundeffekte, Animationen und Events. Wenn du versuchen würdest, das alles auf einmal zu laden, würde der Arbeitsspeicher deines Systems schnell an seine Grenzen stoßen. Diese Beschränkung existiert zwar auch auf den leistungsstärksten Computern, ist aber auf Geräten mit eingeschränkter Hardware wie etwa Meta Quest besonders relevant.
Glücklicherweise erleben die Spieler*innen zu jedem Zeitpunkt nur einen kleinen Bruchteil der Welt. Stelle dir etwa einen Wolkenkratzer vor: Er besteht zwar aus Hunderten von Etagen und Tausenden von Räumen, aber die Spieler*innen interagieren nur jeweils mit einer oder zwei Etagen auf einmal. In Outdoor-Umgebungen interagieren die Spieler*innen ebenfalls nur mit ihrer unmittelbaren Umgebung.
In beiden Beispielen müssen wir daher die maximale Qualität der Spiele-Assets nur laden, wenn die Spieler*innen möglicherweise mit ihnen interagieren werden. Alles, was weit genug entfernt ist, kann mit einer niedrigeren Qualität oder überhaupt nicht geladen werden. Wenn sich die Spieler in der Welt umherbewegen, können wir zuvor besuchte Bereiche aus dem Arbeitsspeicher entfernen, um Platz für neue Bereiche zu schaffen.
Deine Speicherprobleme lassen sich zwar möglicherweise nicht so einfach beheben, aber dieses Beispielprojekt ist zumindest ein Schritt in die richtige Richtung. Darin beschreiben wir, wie wir unsere Assets beurteilen (aus dem Oculus Studios-Titel Dead & Buried 2), die Performance des Spiels zur Laufzeit messen, Assets mit unterschiedlichen Detailstufen (Level of Detail, LOD) sowie ein System erstellen, das diese LODs je nach Position der Spieler*innen lädt und Tools schreiben, um die Asset-Verwaltung in unserem Spiel zu testen. Zum Abschluss empfehlen wir weitere Möglichkeiten, um die Laufzeit-Performance des Erlebnisses zu verbessern.
Dieses Beispiel befasst sich mit dem Laden der Level-Geometrie. Echte Projekte müssen sich auch mit Spielobjekten und deren unzähligen Komponenten befassen, wie etwa Audio, Animation, zusätzliche Meshes und vieles mehr. Wenn du dich mit diesen Aspekten befasst, sind die Grundideen aus diesem Artikel jedoch weiterhin gültig. Bereite deine Assets für unterschiedliche Detailstufen vor, lade nicht mehr als nötig und versuche nicht, all deine aufwändige Arbeit in ein Einzelbild zu stopfen.

Komplexität von Assets bewerten

Um unsere Assets optimieren zu können, müssen wir sie zunächst verstehen. Dieses Beispiel konzentriert sich darauf, die Komplexität der Umgebung zu reduzieren, die sich hauptsächlich aus der Komplexität der Meshes ergibt, aus denen die Umgebung besteht, und den von diesen Meshes verwendeten Materialien und Shadern.

Mesh-Komplexität

Mesh Complexity
Um die Mesh-Komplexität in einer Szene zu überprüfen, können wir zuallererst das Drahtgittermodell im Editor aktivieren. In dieser Szene sieht die Mesh-Komplexität recht gut aus. Die meisten Dreiecke decken viele Pixel ab, aber auf der Treppe und der Kirche sehen wir viele sehr dünne Dreiecke. Wir erkennen keine kleinen Details von Assets in großer Entfernung, daher können wir sie mit niedrigeren LODs laden.
Aktiviere das Statistikfenster, um die Anzahl der Dreiecke in der aktuellen Ansicht zu sehen. Diese Ansicht enthält 525.000 sichtbare Dreiecke. Das ist ziemlich viel. Beachte jedoch, dass diese Ansicht immer noch reine Editor-Primitive sowie nicht verwendete UI-Elemente rendert und das System für Verdeckungs-Culling nicht nutzt. Im Spiel wäre die tatsächliche Anzahl der Dreiecke viel niedriger.
Die Dokumentationsliste Dreiecksanzahl auf Meta Quest führt die Zielwerte für die Anzahl an Dreiecken pro Headset auf, doch große Open-World-Level sollten 50 % des Zielwerts erreichen. Das liegt daran, dass diese Zielwerte aus der Untersuchung von Arbeitslasten stammen, die kleinere, in sich geschlossene Level rendern. Kleinere, in sich geschlossene Level können eine komplexere Geometrie enthalten, da die Renderdistanz kürzer ist und das Verdeckungs-Culling effektiver funktioniert. Nach dem Culling werden die meisten Dreiecke nicht mehr gerendert, auch wenn sie sich genau genommen noch im Sichtkegel befinden. In größeren, offenen Levels muss die Komplexität der Geometrie angepasst werden, da das Culling weniger effektiv ist. Beide Szenarien hängen jedoch stark von der Komplexität deiner Shader ab.
Zuletzt ist es wichtig, die Anzahl der kleinen Dreiecke auf dem Bildschirm zu reduzieren. Damit sinkt nicht nur die Anzahl der Vertices, sondern auch die Menge der zusätzlich gerenderten Samples, wenn MSAA aktiviert wird.

Shader-Komplexität

Shader code
Um die Shader-Komplexität zu überprüfen, sehen wir uns zunächst den Code an. In diesem Fall sehen wir sofort, dass die Shader relativ einfach sind. Es gibt überhaupt kein direktes Licht. Sämtliches Licht stammt aus der Lightmap, daher lässt sich die Farbe von Fragmenten mit wenigen Texturabtastungen und etwas Skalarmultiplikation sehr einfach bestimmen.
Shader disassembly and statistics
Um die Shader-Komplexität genauer zu messen, kannst du den Shader im Projekt-Browser und dann die Option Compile and show code für D3D auswählen. Daraufhin wird die Shader-Disassembly zusammen mit grundlegenden Statistiken ausgegeben. Disassembly ist für das menschliche Auge schwer zu lesen, aber komplexe Shader sind recht einfach daran erkennbar, dass sie mehr Operationen benötigen. Als Faustregel gilt, dass höhere Werte und mehr Zeilen an Disassembly-Code auf langsamere Shader hindeuten.

Draw-Aufrufe messen und reduzieren

Eine sehr hohe Priorität beim Optimieren der GPU-Performance besteht darin, die Anzahl der draw-Aufrufe pro Einzelbild zu reduzieren. Weitere Informationen findest du in diesen Artikeln:
Nach der Lektüre dieser Artikel kannst du hier fortfahren.
View of a level
Hier sehen wir einen Teilabschnitt des Levels vom südlichsten Punkt aus mit Blick nach Norden. Unity kombiniert draw-Aufrufe mit gleichen Materialien und Eigenschaften zu sogenannten „statischen Batches“. Diese Ansicht führt nach praktisch jedem draw-Aufruf einen setpass-Aufruf aus, wodurch diese statischen Batches wirkungslos werden. Die Ursache dafür liegt beim Lightmapping. Der Level enthält insgesamt 12 Lightmaps, und nahe beieinander liegende Objekte befinden sich oft in unterschiedlichen Lightmaps.
Draw calls with original lightmaps
Wir können diese Meshes in dieselbe Lightmap versetzen, um die Anzahl der setpass-Aufrufe zu reduzieren. Dazu haben wir Lightmap Parameters Assets verwendet. Auf diese Weise kannst du den System Tag-Parameter verwenden, um Objekte einer Gruppe zuzuweisen. Außerdem kannst du begrenzen, wie viele Lightmaps die Objekte, die das Lightmap-Parameter-Asset verwenden, generieren dürfen. Wir versetzen das Gelände nach lightmap 0 und die restlichen Meshes nach lightmap 1. Damit haben wir ausreichend gut aussehende Lightmaps erstellt. Außerdem haben wir die Anzahl der setpass-Aufrufe von 148 auf nur 16 reduziert. Die Gesamtzahl der Batches ist von 156 auf 94 gesunken.
Draw calls with optimized lightmaps
Du kannst die Anzahl der Lightmaps auch reduzieren, indem du den Parameter Lightmap Resolution in deinen Lightmap-Einstellungen reduzierst. Damit entsteht derselbe Effekt, aber du hast weniger Kontrolle.

Mesh-Batching und LOD-Generierung

Hinweis: Mesh-Batching ist der Vorgang, bei dem Meshes zu einem großen Mesh kombiniert werden. „Statisches Batching“ ist die Art und Weise, wie Unity draw-Aufrufe zu Batches zusammenfasst. Ein statischer Batch enthält einen einzigen setpass-Aufruf, kann aber mehrere draw-Aufrufe enthalten. Ein als Batch zusammengefasstes Mesh enthält nur einen draw-Aufruf.
Unser Level enthält eine Mischung aus komplexen und modularen Meshes. Gebäude verwenden oft komplexe Meshes, die später mit kleinen, modularen Elementen dekoriert werden. Teile des Levels bestehen komplett aus modularen Elementen. Ohne Mesh-Batching wären dafür Tausende von draw- und setpass-Aufrufen nötig. Außerdem hat keines dieser Meshes LODs, daher wird auch dann die maximale Komplexität der Meshes verwendet, wenn das Objekt auf dem Bildschirm kaum sichtbar ist.
Meshes
In diesem Screenshot sehen wir, dass das Gebäude und der Fels aus großen Meshes bestehen. Die Holzscheite und das Zelt sind mittelgroß, und das Eis auf der Tür ist klein.
Außerdem verwenden die Shader keine direkte Beleuchtung. Die gesamte Beleuchtung stammt aus den Lightmaps. Lightmaps können statische Batches stören, daher ist es wichtig, dass Meshes soweit wie möglich dieselbe Lightmap verwenden. Außerdem ist es wichtig, dass alle LODs eines Meshes dieselbe Lightmap verwenden. Auf diese Weise schonst du den Arbeitsspeicher (weniger Lightmaps), sorgst für Einheitlichkeit beim Wechsel zwischen LODs und musst die Lightmap nur einmal erstellen anstatt einmal pro LOD. Wenn deine Assets LODs enthalten, solltest du darauf achten, dass ihre Lightmap-UVs mit dem LOD0-Mesh (höchste Detailstufe) übereinstimmen.

Ziele

Unity profiler rendering window
Dieser Screenshot zeigt das Renderingfenster des Unity-Profilers zu Anfang unserer Betrachtung. Diese Ansicht enthielt 4.105 draw-Aufrufe, 129 setpass-Aufrufe sowie 342 statische Batches und wir haben 1,4 Millionen Dreiecke gerendert.
Erfahrungsgemäß sollten wir für das ursprüngliche Quest-Gerät mit GLES API ein Maximum von 100 draw-Aufrufen für die statische Geometrie im Level sowie insgesamt 200 draw-Aufrufe anstreben, inklusive aller dynamischer draw-Aufrufe. Auf Quest 2 oder mit der Vulkan API können wir etwas höhere Zielwerte verwenden.
Die maximale Anzahl an Dreiecken hängt von der Komplexität der Shader ab. In unserem Beispiel verwenden wir einen sehr einfachen Fragment-Shader und haben daher relativ viel Spielraum auf der GPU. Daher können wir viele Dreiecke rendern. Erfahrungsgemäß müssen wir uns auf dem ursprünglichen Quest-Gerät mit einem Fragment-Shader und Lightmapping, direkter Beleuchtung aus einer einzelnen, direktionalen Lichtquelle und normalen sowie spiegelnden Maps auf etwa 300.000 Dreiecke beschränken. In unserem Beispiel, das nur Lightmapping verwendet, können wir problemlos mehr als eine Million Dreiecke rendern.
Ein besonders wichtiger Aspekt sind die setpass-Aufrufe. Setpass-Aufrufe im Rendering-Thread können sehr ressourcenaufwändig sein, insbesondere in Kombination mit GLES. Ein setpass-Aufruf findet immer dann statt, wenn ein Material zwischen draw-Aufrufen ausgetauscht wird. Wir hätten gerne nur einen einzigen setpass-Aufruf für die gesamte statische Geometrie im Level. Hiervon ausgenommen sind Elemente wie Skybox und Gelände, die im Vergleich zur normalen Geometrie völlig andere Shader verwenden. Dazu musst du deine Texturen entweder zu einem Texturatlas oder einem Textur-Array kombinieren und die Meshes und Shader so anpassen, dass sie die passenden Texturkoordinaten verwenden.
Unsere Welt ist eine Kombination aus 4 Unterlevels, die anschließend wiederholt werden und ein große Welt bilden. Wir möchten gerne jeden Unterlevel separat erstellen können. Auf diese Weise können wir unterschiedliche LOD-Einstellungen pro Unterlevel verwenden. Wenn sich ein Unterlevel ändert, müssen wir außerdem nur diesen Level neu erstellen, anstatt alle 4.

Unser Ansatz

Für jeden Unterlevel führen wir die folgenden Schritte aus: Materialien erstellen, Lightmap-Mesh generieren, Lightmaps erstellen, LODs generieren und LOD-Struktur erstellen.

Materialien erstellen

In diesem Schritt erstellen wir ein Material, das wir für alle LOD-Meshes verwenden werden. Wir verwenden Texturatlanten, um die von den ursprünglichen Materialien verwendeten Texturen zu kombinieren. Wir haben nicht viele einzigartige Texturen pro Level, daher passen alle Texturen ohne Qualitätseinbußen in eine einzige Textur mit 4096x4096.
Wenn ein Texturatlas mit 4096x4096 nicht ausreicht, um alle Texturen zu speichern, ist es unter Umständen hilfreich, die Texturen zu einem Textur-Array zu kombinieren. Nach dem Generieren der neuen Texturen kannst du die Texturkoordinaten der Meshes anpassen, um die passenden Textur-UVs zu verwenden.

Lightmap-Mesh generieren

Wir haben all unsere Meshes zu einem großen Mesh zusammengefasst. Anschließend haben wir dieses Mesh mit Unity ausgepackt, um Lightmap-UVs zu generieren. Mit diesem Mesh werden wir die Lightmaps generieren und dann die Lightmap-UVs aus diesem Mesh auf unsere LOD-Meshes kopieren.

Lightmaps erstellen

Wir erstellen die Lightmaps mit dem in Unity integrierten Lightmapper.

LODs generieren

Unser System kombiniert Meshes, indem es sie zu Rasterzellen gruppiert. Jede Rasterzelle erstellt ein als Batch zusammengefasstes Mesh aller enthaltenen Meshes. Jede LOD-Stufe erstellt Zellen mit der doppelten Größe der vorherigen Stufe. Auf diese Weise können wir sie später in einem Quadtree-basierten hierarchischen LOD-System verwenden. Um die Komplexität in weit entfernten Meshes zu reduzieren, entfernen höhere LOD-Stufen kleine Meshes vor dem Batching. Anschließend fassen wir die Meshes zu Batches zusammen, um eine LOD für die Rasterzelle zu erstellen. Um die Anzahl der Dreiecke noch weiter zu reduzieren, kannst du auch alle Dreiecke unter dem Gelände entfernen.
Nach dem Generieren kopieren wir die Lightmap-UVs aus unserem Lightmap-Mesh in unser LOD-Mesh. An dieser Stelle haben alle Vertices im Lightmap-Mesh einen identischen Vertex, da wir lediglich Vertices aus dem ursprünglichen Mesh entfernt haben.
Anschließend reduzieren wir den Mesh-Batch auf allen Stufen mit Ausnahme von LOD0, wobei jede Stufe mehr Fehler zulässt als die vorherige. Dieser Vorgang muss nach dem Kopieren der Lightmap-UVs ausgeführt werden, da er die Vertexposition verändern kann.
LOD meshes
Hier sehen wir Zellen als weiße Linien dargestellt. Die grünen Quadrate sind LOD0-Meshes. Die blauen Quadrate sind LOD1-Meshes. Das rote Quadrat ist ein LOD2-Mesh. Jedes Quadrat ist ein einzelnes Mesh. In LOD0 siehst du immer noch viele kleine Objekte (Lagerfeuer, Holzscheite). In LOD1 wurden diese kleinen Objekte entfernt, aber Bäume und Gebäude sind noch sichtbar. In LOD2 sind nur noch die größten Objekte übrig (Gebäude, Felsen). Die LOD2-Lightmap enthält weiterhin Schatten an Stellen, an denen Meshes entfernt wurden. Dies liegt daran, dass alle LOD-Stufen dieselben Lightmaps verwenden.

LOD-Struktur erstellen

Als Nächstes erstellen wir eine Baumstruktur für die generierten Meshes. Außerdem speichern wir die Lightmap-Textur zusammen mit ihrem Offset und ihrer Größe. Wir kopieren die Collider aus den ursprünglichen Meshes und hängen sie an ein separates GameObject an.
Weitere Details findest du in diesen Dateien:

Bibliotheken von Drittanbietern

In unserer Implementierung des LOD-Generators haben wir das Paket Mesh Baker aus dem Asset Store verwendet. Wir empfehlen dringend, Mesh Baker zum Erstellen von Texturatlanten und zum Kombinieren deiner Meshes zu verwenden. Alternativ kannst du etwas eigenes schreiben oder eine Lizenz für eine Lösung wie Simplygon erwerben.

Sublevel kombinieren

Nachdem wir die LODs für alle Sublevel generiert haben, können wir sie kombinieren, um den abschließenden Level zu erstellen.

Lightmapping

Unity speichert alle Lightmapping-Informationen in einem Lightmap-Data-Asset. Dieses Asset kann nicht erstellt oder verändert werden. Wenn wir die Sublevel zum abschließenden Level kombinieren, verlieren wir also die Lightmap-Daten. Als Abhilfe können wir alle Lightmaps zu einem Textur-Array kombinieren. Wir passen die Shader so an, dass sie dieses Textur-Array anstelle der standardmäßigen Lightmap-Textur verwenden. Außerdem passen wir die Lightmap-UVs der Meshes an und fügen Offset und Größe der Lightmap hinzu.
Beachte dabei, dass das Lightmap-Textur-Array recht viel Arbeitsspeicher verbrauchen kann. Daher ist es wichtig, dass das Lightmap-Textur-Array in einem komprimierten Format vorliegt. Dazu kannst du zunächst die Ausgangstextur in das gewünschte Format komprimieren und dann mit Blitting in das Textur-Array einfügen.
Es ist wichtig, Offset, Größe und Index der Lightmap als Vertex-Attribut zu speichern, um unnötige setpass-Aufrufe zu vermeiden.

Szenen

Um die Meshes per Streaming laden zu können, sollte jedes LOD-Mesh einer eigenen Szene hinzugefügt werden. Jede Szene sollte nur ein eigenes Mesh enthalten. Das Mesh sollte bereits per Offset auf die endgültige Position versetzt werden. Szenen an sich enthalten keine Transformation. Ohne Offset werden die Meshes also allesamt am Ursprung des Levels erzeugt.
Die Szenen sollten zu den Build-Einstellungen hinzugefügt werden. Anschließend erhält das LOD-System den Index der Szene, die das passende LOD-Mesh in den Build-Einstellungen enthält.

LOD-System

Das System zur Verwaltung von aktuellem und angestrebtem Status der Mesh-LODs muss möglichst effizient arbeiten. Die Quest-Hardware ist nicht in der Lage, den Abstand aller Meshes zur Kamera für jedes Einzelbild zu überprüfen. Die zum Überprüfen des LOD-Status aufgewendete Zeit fehlt anschließend für das restliche Spiel.

Zwei Herangehensweisen

Zunächst haben wir ein rasterbasiertes System verwendet, in dem jede Zelle ihren Abstand zur Kamera selbst berechnet, von der höchsten LOD (LOD2, die niedrigste Detailstufe) bis zur niedrigsten (LOD0, maximale Details). Die LOD-Stufe einer Zelle durfte nur überschrieben werden, wenn alle Meshes im von der höheren LOD abgedeckten Bereich anschließend durch die niedrigere LOD ersetzt würden, um Lücken zu vermeiden. Dieses System ist leider langsam und skaliert schlecht in größeren Leveln mit mehr Zellen. Dieser Ansatz macht nur Sinn, wenn du vermeiden möchtest, dass jede LOD-Stufe genau doppelt so groß ist wie die vorherige. LOD0 und LOD1 können beispielsweise gleiche Zellengrößen haben, aber LOD1 kann trotzdem ein einfacheres Mesh verwenden.
Inzwischen verwenden wir einen hierarchischen, Quadtree-basierten Ansatz. Damit können wir größere Bereiche sehr effizient abdecken. Wenn wir feststellen, dass eine Zelle mit LOD2 gerendert werden sollte, können wir die Überprüfung aller LOD1- und LOD0-Zellen komplett überspringen, da sie im LOD2-Bereich enthalten sind. Daher werden die meisten Zellen nie überprüft. Mit einer hierarchischen Struktur können wir auch das Streaming vereinfachen. In einem trägen Streaming-System kannst du beispielsweise das übergeordnete LOD anzeigen, bis alle LODs fertig geladen wurden. Alternativ kannst du jederzeit die untergeordneten Zellen der aktuell angezeigten Zelle laden, jedoch auf Kosten des Arbeitsspeichers.
LOD cells
Wenn die Kamera die gelbe Rasterzelle belegt, erhältst du die folgende LOD-Struktur. Green bedeutet LOD0, Blau ist LOD1 und Rot ist LOD2. In diesem Fall wurden 20 LOD2-Meshes geladen, aber nur 14 davon sind sichtbar. 24 LOD1-Meshes wurden geladen, und 20 sind sichtbar. 16 LOD0 wurden geladen, und sie sind allesamt sichtbar. Die weißen Zellen am äußeren Rand sind Knoten aus der Octree-Struktur und haben keine Meshes.

LOD-Status

Immer, wenn sich die Kamera in eine andere Zelle bewegt, durchlaufen wir die Baumstruktur von oben nach unten. Auf jeder Ebene berechnen wir die Distanz zur Kamera in den Zellen. Die Zellen sind jeweils halb so groß wie auf der vorherigen Ebene. Wenn die Distanz zur Kamera kleiner als eine Zelle ist, laden wir das Mesh, ohne es jedoch anzuzeigen, und gehen zur nächsten Stufe. Wenn wir nicht weiter nach unten gehen müssen oder einen Blattknoten erreicht haben, laden wir das entsprechende Mesh für den Knoten und zeigen es an. Knoten, die geladen werden, benachrichtigen ihren übergeordneten Knoten.
Im ersten Durchlauf berechnen wir den Status, in dem sich die einzelnen Knoten befinden sollten. Im nächsten Durchlauf wenden wir den richtigen Status an. Dies liegt daran, dass wir keine untergeordneten Knoten anzeigen möchten, bevor alle untergeordneten Knoten fertig geladen wurden. Andernfalls kann es passieren, dass Meshes plötzlich im Sichtfeld erscheinen. Wenn ein Knoten weiß, dass einer seiner untergeordneten Knoten geladen wird, macht er sich selbst sichtbar und sorgt dafür, dass seine untergeordneten Knoten unsichtbar bleiben. Wenn ein untergeordneter Knoten fertig geladen ist, benachrichtigt er den übergeordneten Knoten. Der übergeordnete Knoten aktiviert die untergeordneten Knoten und deaktiviert sich selbst, wenn sie alle geladen wurden.
Um schnelle Änderungen des LOD-Status zu vermeiden, wenn sich die Kamera am Übergang zwischen Zellen befindet, verhindern wir Aktualisierungen des LOD-Systems, bis die Kamera mindestens einen Meter von der Position entfernt ist, für die wir die LOD-Status zuletzt berechnet haben.
Weitere Details findest du in diesen Dateien:

Asynchrones Laden und Freigeben

Zu Anfang haben wir Szenen geladen, wenn wir sie gebraucht haben, und freigegeben, wenn wir sie nicht mehr gebraucht haben. Da die Funktionen zum Laden und Freigeben jedoch asynchron sind, können dabei Racebedingungen entstehen, wenn du nicht die nötigen Vorkehrungen triffst.
Bei unserer ersten Iteration des LOD-Streaming-Systems sind zwei schwere Probleme aufgetreten. Einerseits wurden Meshes manchmal doppelt und mit unterschiedlichen LODs angezeigt, und andererseits blieben Meshes manchmal auf einer hohen LOD-Stufe stecken. Diese Probleme entstanden dadurch, dass eine Teilszene freigegeben wurde, bevor sie fertig geladen war, bzw. dass eine Szene geladen, freigegeben und erneut geladen wurde, bevor der erste Ladevorgang abgeschlossen wurde.
Hinweis: In Unity können asynchrone Operationen nach dem Starten nicht abgebrochen werden. Außerdem können Szenen nicht freigegeben werden, bevor sie komplett geladen wurden.
Diese beiden Probleme wurden mit einer Warteschlange für Lade- und Freigabeoperationen gelöst. Wir starten eine neue Operation erst, wenn die vorherige abgeschlossen wurde. Damit treten die oben beschriebenen Probleme nicht mehr auf. Es wird also nie versucht, eine Szene freizugeben, die noch nicht fertig geladen wurde. Als Abkürzung für den zweiten Fall kann der zweite Ladevorgang den Freigabevorgang aufheben, wodurch beide übersprungen werden können.
Ein Großteil der Arbeit findet zwar asynchron in einem anderen Thread statt, aber das Starten der asynchronen Operationen im Hauptthread kann erheblichen Aufwand verursachen. Daher haben wir ein System implementiert, das die Arbeit auf mehrere Einzelbilder verteilt.

Assets freigeben

Beim additiven Laden einer Szene werden alle in der Szene verwendeten Assets ebenfalls geladen. Beim Freigeben der Szene werden die Assets jedoch nicht freigegeben. Daher nimmt die Arbeitsspeicherauslastung immer weiter zu, bis sich alle LODs im Arbeitsspeicher befinden.
Bei knappem Arbeitsspeicher auf Quest-Geräten können Aussetzer auftreten, bis hin zu eingefrorenen oder abgestürzten Apps. Dies liegt daran, dass das System einen Hintergrundprozess ausführt, der den Arbeitsspeicher bereinigt.
Unity stellt zwei Möglichkeiten bereit, mit denen du nicht mehr benötigte Assets freigeben kannst:
  • Resources.UnloadUnusedAssets ist eine sehr langsame Operation, bei der alle nicht verwendeten Assets freigegeben werden. Diese Operation eignet sich nicht für den Einsatz im Gameplay. Sie verursacht mehrere ausgelassene Einzelbilder. Du kannst sie höchstens dann verwenden, wenn sich die Spieler*innen teleportieren oder in einem Ladebildschirm befinden.
  • Resources.UnloadAsset kann eingesetzt werden, um Assets im Gameplay freizugeben. Diese Operation ist ebenfalls ressourcenaufwändig (das Freigeben eines einzelnen Meshes kann etwa 0,3 ms im Hauptthread dauern) und sollte daher mit einem Lastenausgleichssystem eingesetzt werden.
Hinweis: Resources.UnloadAsset funktioniert nur, wenn du das von der Szene verwendete Mesh übergibst, das freigegeben werden soll. Wenn du eine Kopie des Meshes übergibst, wird nur die Kopie freigegeben. Wir haben versucht, alle von einem LOD-Knoten verwendeten Meshes zu serialisieren, um sie nicht zur Laufzeit sammeln zu müssen. Dies hat jedoch nicht funktioniert, da es sich bei den vom Skript serialisierten Meshes um Kopien handelt und nicht um die tatsächlich in der Szene verwendeten Meshes.

Debugging-Tools

Diese Systeme sind komplex. Daher solltest du dich mit Debugging- und Visualisierungstools vertraut machen, um deine Annahmen überprüfen und Fehler untersuchen zu können.

LOD-Debug-Ansicht

Um die angezeigten LODs genau visualisieren zu können, haben wir einen Shader entwickelt, der eine Farbe für einen Ganzzahl-Materialparameter ausgibt (je nach Index der LOD-Stufe: grün für maximale Details und rot für minimale Details). Zur Laufzeit erstellen wir verschiedene Materialien mit diesem Shader. Je ein Material pro LOD-Stufe sowie ein Material für Übergänge zwischen LOD-Stufen. Beim Laden der Szene wird überprüft, ob das LOD-Debugging aktiv ist. Falls ja, wird das entsprechende Material für die jeweilige LOD-Stufe angewendet.
Das Material der Debug-Ansicht hat keine materialspezifischen Eigenschaften. Auf diese Weise können wir dasselbe Material für alle Meshes in einer LOD-Stufe verwenden. Die Mesh-Eigenschaften sind jedoch nach wie vor verfügbar, und da unsere Lightmaps ein globaler Parameter sind, können wir sie verwenden, um Tiefe in der ansonsten flachen Schattierung des Debug-Shaders zu erzeugen.

Benchmark-Modus

Oft ist es hilfreich, dein Spiel in einem vorhersehbaren, deterministischen, automatischen Modus auszuführen, den du beispielsweise jede Nacht automatisch ausführen kannst, um zu prüfen, ob nach einem Tag voller Commits immer noch alles erwartungsgemäß funktioniert.
Für diesen Benchmark-Modus haben wir zunächst ein einfaches Tool erstellt, mit dem du einen Pfad aus Wegpunkten definieren kannst. Die Kamera folgt diesem Pfad mit konstanter Geschwindigkeit. Die Ausrichtung der Kamera ist ebenfalls festgelegt, um Schwankungen in den Benchmark-Ergebnissen durch unterschiedliche Blickrichtungen zu vermeiden.
In unserem ersten Ansatz haben wir Bezier-Kurven verwendet, um einen glatten Pfad zu erstellen. Diese Option ist zwar nicht falsch, erschwert aber die Erstellung des Pfades. Daher verwenden wir inzwischen gerade Linien zwischen Wegpunkten.
Unser Tool platziert die Wegpunkte automatisch genau zwei Meter über dem Boden. Dies ist wichtig, weil sich eine zu hohe oder zu niedrige Kameraperspektive auf die Performance beim Verdeckungs-Culling auswirken kann.
Wir fixieren die Kameraposition nicht auf dem Pfad. Stattdessen schaut die Kamera immer einen Meter nach vorne auf dem Pfad, bewegt sich vorwärts und versucht, eine Lücke von einem Meter beizubehalten. Auf diese Weise werden scharfe Ecken etwas geglättet. Was die Blickrichtung der Kamera angeht, ändern wir nur die Rotation um die Y-Achse. Wir beschränken uns auf die Y-Achse, weil Änderungen der Rotation um die X- oder Z-Achse eine Rollbewegung im OVR-Kamerasystem verursachen, was wiederum dazu führen kann, dass die Kamera auf dem Kopf steht oder sich ihre Vorwärtsrichtung mit jedem Einzelbild ändert.

Frei fliegende Kamera

Die Funktionsweise des LOD-Systems lässt sich recht einfach überprüfen, indem du alle Kacheln im Einzelbild auf einmal anzeigst, und dies lässt sich nur mit der frei fliegenden Kamera erreichen.
Für diesen Modus haben wir das in den Unity Starter Samples enthaltene Skript SimpleCapsuleWithStickMovement so angepasst, dass es Bewegungen in Y-Richtung unterstützt. Außerdem mussten wir die Schwerkraft für das Rigidbody-Objekt deaktivieren. Wie auch im Benchmark-Modus solltest du das OVR-Kamerasystem ausschließlich um die Y-Achse rotieren.

LOD-Stufe erzwingen

Das LOD-System erhöht die Detailstufe, wenn sich die Kamera nähert. Während der Entwicklung kann es jedoch vorkommen, dass wir uns die Meshes mit niedriger Qualität aus der Nähe ansehen möchten. Zu diesem Zweck haben wir einen Modus entwickelt, mit dem du die LODs auf einer bestimmten Stufe einfrieren kannst.
Vergiss beim Erzwingen der LOD-Stufe nicht, dass die App mehr Arbeitsspeicher als normal verbraucht, wenn du alles auf LOD0 festlegst. In diesem Beispiel ist der Arbeitsspeicher nicht begrenzt, daher können wir problemlos alle LOD0-Meshes gleichzeitig laden, aber in komplexeren Umgebungen ist dies unter Umständen nicht möglich. In solchen Umgebungen kann es hilfreich sein, die LOD-Stufe nur für Zellen zu erzwingen, die normalerweise sichtbar wären (wie in diesem Beispiel geschehen). Wenn eine Zelle so weit entfernt ist, dass nicht einmal LOD2 (unsere höchste LOD-Stufe) angezeigt wird, dann müssen wir die LOD für die Zelle auch nicht erzwingen. Dazu kannst du die Baumstruktur zunächst ganz normal durchlaufen, bis du die LOD2-Knoten erreichst, und dich anschließend in der Baumstruktur nach unten bewegen, bis du die gewünschte LOD-Stufe erreicht hast.

LOD-Stufe einfrieren

Um die LOD-Stufen der Meshes einzufrieren, reicht es aus, die Aktualisierungen des LOD-Systems einzustellen.
Weitere Details findest du in diesen Dateien:

LOD-System verwenden

Das LOD-System kann ohne Änderungen oder Verbesserungen im vorliegenden Zustand verwendet werden. Dies gilt jedoch leider nicht für den LOD-Generator, da wir Teile des Codes entfernen mussten. Um das LOD-System verwenden zu können, musst du eigene LOD-Meshes generieren. Weitere Abschnitte findest du oben im Abschnitt Bibliotheken von Drittanbietern.
Die LODs müssen einem Rasterschema folgen und jede LOD-Stufe sollte doppelt so groß sein wie die vorherige Stufe. Dies ist erforderlich, weil das LOD-System intern eine Quadtree-Struktur verwendet. Rufe LODManager.SetLOD auf, um den LOD-Manager auszufüllen. Daraufhin wird eine Quadtree-Struktur mit den übergebenen Objekten erstellt. An dieser Stelle verwenden die LODs kein Streaming, sondern die Meshes werden geladen (aktiviert bzw. deaktiviert).
Um Mesh-Streaming zu aktivieren, musst du ein Sublevel-Combiner-Skript in deiner Szene platzieren und es ausführen. Damit wird eine Szene für jedes Mesh erstellt und der LOD-Manager für das Streaming eingerichtet. Diese Systeme treffen Annahmen, was die Materialien, das Lightmapping und die Kollisionskonfiguration deiner LODs angeht. Diese Annahmen müssen unter Umständen an die jeweilige Umgebung angepasst werden.

Verdeckungs-Culling

Je nach Spiel lassen sich mit Verdeckungs-Culling unter Umständen dramatische Performance-Optimierungen erzielen. In unserem Beispiel laufen wir auf dem Boden zwischen hohen Gebäuden und/oder großen Felsformationen umher. Beides eignet sich sehr gut für Verdeckungs-Culling.

Integriertes System für Verdeckungs-Culling

Das in Unity integrierte System für Verdeckungs-Culling ist recht einfach zu verwenden. Außerdem können die Verdeckungsdaten recht schnell generiert werden und sind ziemlich zuverlässig. Dieses System eignet sich jedoch nicht für Mobilgeräte. Das System verbraucht große Mengen an Arbeitsspeicher, und zwar unabhängig von der Größe der Verdeckungsdaten. Außerdem ist es ziemlich CPU-aufwändig und verbraucht viel Zeit im Haupt-Thread.
Wir haben das integrierte Verdeckungssystem trotz dieser Nachteile verwendet, da wir weder durch Arbeitsspeicher noch durch CPU eingeschränkt sind. Um die Verdeckungsdaten für die Szene zu generieren, musst du zunächst alle LOD-Meshes laden (Szenen mit den LOD-Meshes additiv laden). Anschließend kannst du die Verdeckungsdaten wie gewohnt generieren.

Selbstdefiniertes System für Verdeckungs-Culling

In komplexen Spielen kann das integrierte System für Verdeckungs-Culling aus den genannten Gründen nicht eingesetzt werden. In diesem Fall kannst du ein eigenes System für Verdeckungs-Culling erstellen, das sich besser für Mobilgeräte eignet.
Für statische Geometrie lässt sich ein solches System relativ einfach implementieren. Zunächst teilst du die Szene in Zellen auf. Für jede Zelle renderst du die Szene in eine Cubemap mit einem selbstdefinierten Material, das einen eindeutigen Bezeichner für das jeweilige Mesh ausgibt. Anschließend liest du die Cubemap zurück in die CPU und sammelst die Bezeichner aller sichtbaren Meshes. Diese Ergebnisse speicherst du, um sie zur Laufzeit abrufen zu können. Zur Laufzeit deaktivierst du die nicht in der Liste enthaltenen Meshes, wenn du eine Zelle betrittst. Diese Methode erfordert keine Verdeckungsprüfungen zur Laufzeit und ist daher sehr CPU-schonend.
Das war eine sehr vereinfachte Erklärung. Eine naive Implementierung kann unter Umständen dazu führen, dass die Performance schlechter ist als es mit dem integrierten System für Verdeckungs-Culling der Fall wäre. Die Erstellungsdauer für die Verdeckungsdaten kann je nach Größe der Level und der Art und Weise der Implementierung auch Probleme verursachen.