BatchRendererGroup-Beispiel: Erreichen Sie hohe Bildraten auch auf preisgünstigen Geräten

In diesem Beitrag beschreiben wir ein kleines Shooter-Spielbeispiel, das mehrere interaktive Objekte animiert und rendert. Viele Demos sind nur für High-End-PCs gedacht, hier besteht das Ziel jedoch darin, mit GLES 3.0 eine hohe Bildrate auf einem preisgünstigen Telefon zu erreichen. Dieses Beispiel verwendetBatchRendererGroup, den Burst-Compilerund dasC#-Jobsystem. Es läuft in Unity 2022.3 und erfordert keine Entities- oder entities.graphics-DOTS-Pakete.
Fangen wir an!
Lassen Sie uns direkt zu dem Beispiel springen. Dieses Beispiel läuft mit konstanten 60 fps auf einem preisgünstigen Samsung Galaxy A51 von 2019 (mit einer Mali G72-MP3 GPU). Die Grafik-API ist auf GLES 3.0 eingestellt.
Sie können den Code studieren und auf Ihrer bevorzugten Plattform ausprobieren, indem Sie das Projekt von GitHubherunterladen. Sie benötigen nur die Standardversion von Unity 2022.3.
In diesem Beitrag konzentrieren wir uns hauptsächlich auf BatchRendererGroup und die Beispielklasse BRG_Container.cs. Sie können den Animations- und Physikcode auch in den Klassen BRG_Background.cs und BRG_Debris.cs studieren.
Lassen Sie uns zunächst untersuchen, was wir sehen, bevor wir näher auf die Herstellung eingehen.
- Der Hintergrundboden ist aus vielen Würfeln aufgebaut. Alle Kästen sind animiert und bewegen sich auf und ab.
- Das Hauptschiff bewegt sich horizontal auf dem Bildschirm und schießt Raketen auf farbige Kugeln. (Sie können Raketen schneller abschießen, indem Sie auf den Bildschirm tippen.)
- Wenn eine Rakete über den Boden fliegt, werden die Bodenzellen durch ein Magnetfeld leicht angehoben und hervorgehoben. Außerdem werden dabei Bodentrümmer in die Luft geschleudert.
- Wenn eine Rakete eine Kugel trifft, explodiert sie und zerfällt in farbige Trümmer.
- Wenn Trümmer auf den Boden fallen, blinkt die kollidierende Zelle auf dem Boden weiß. Je mehr Fremdkörper auf eine Zelle treffen, desto dunkler wird ihre Farbe. Zudem hinterlässt das Gewicht der Trümmer Vertiefungen im Boden.
Sowohl die Bodenzellen als auch die Trümmer bestehen aus Würfeln. Jeder Würfel hat eine andere Position und Farbe. Wir möchten alles mithilfe der CPU animieren und verwalten, um die Interaktionen zwischen Boden und Schutt zu vereinfachen. (Trümmer sind nicht nur ein kosmetisches visuelles Element und können daher nicht nur mit der GPU erstellt werden.)
Für das Rendering erstellen wir kein GameObject pro Element, um unnötige Leistungseinbußen auf einem mobilen Gerät der unteren Preisklasse zu vermeiden. Stattdessen verwenden wir die neu eingeführte BatchRendererGroup-API.
Graphics.DrawMeshInstanced ist eine praktische und schnelle Möglichkeit, viele ähnliche Meshes an unterschiedlichen Positionen zu rendern. Im Vergleich zur BatchRendererGroup-API weist es jedoch die folgenden Einschränkungen auf:
- Es ist erforderlich, ein verwaltetes Speicherarray mit Matrizen bereitzustellen, sodass es zu einer Garbage Collection kommen kann. Außerdem werden invertierte Matrizen von der CPU berechnet, auch wenn der Shader dies nicht benötigt (z. B. bei URP/unlit).
- Wenn Sie eine andere Eigenschaft als die obj2world-Matrix anpassen möchten (z. B. eine Farbe pro Instanz), müssen Sie Ihren eigenen benutzerdefinierten Shader bereitstellen, indem Sie ihn entweder von Grund auf neu schreiben oder Shader Graph verwenden.
- Matrix- oder benutzerdefinierte Daten müssen bei jeder Ziehung in den GPU-Speicher hochgeladen werden. Mit Graphics.DrawMeshInstanced können Sie keine persistenten GPU-Speicherdaten haben. Je nach Kontext kann dies zu erheblichen Leistungseinbußen führen.
BatchRendererGroup (oder BRG) ist eine API, die effizient Zeichenbefehle aus C# generiert und GPU-Instanziierungs-Zeichenaufrufe erzeugt. Da kein verwalteter Speicher verwendet wird, können Sie Befehle auch mit dem Burst-Compiler generieren.

Tipp: Das Paket entities.graphics dient zum Rendern von Entitäten (ECS-Paket) und basiert auf BRG. entities.package übernimmt für Sie die gesamte GPU-Speicherverwaltung und die Erstellung optimaler Zeichenbefehle. In diesem Beispiel verwenden wir kein ECS und steuern daher direkt BRG an.
BRG verwendet ein spezielles GPU-Datenlayout und eine dedizierte Shader-Variante. Die Shader-Variante kann Daten aus dem Standard-Konstantenpuffer (UnityPerMaterial) oder aus einem benutzerdefinierten, großen GPU-Puffer (BRG-Raw-Puffer) abrufen. Sie können selbst entscheiden, wie Sie Ihre Daten im Rohpuffer speichern, bei dem es sich um ein Shader Storage Buffer Object (SSBO oder Byte Address Buffer) handelt. Das standardmäßige BRG-Datenlayout ist vom Typ „Array-Struktur“ (SoA).
Sie können beliebige Eigenschaften eines Materials instanziieren, ohne einen benutzerdefinierten Shader erstellen zu müssen. Im Beispiel möchten wir eine Obj2World-Matrix (zum Positionieren der Würfel), eine World2Obj-Matrix (für die Beleuchtung) und eine BaseColor-Matrix pro Boxinstanz instanziieren (da jede Bodenzelle oder jeder Schutt eine eigene Farbe hat).
Alle anderen Eigenschaften sind für alle Würfel gleich (z. B. der Glättewert) und Sie können mithilfe von Metadaten beschreiben, welche Eigenschaften pro Instanz benutzerdefinierte Werte haben.
Die BRG-Metadaten sind ein optionaler 32-Bit-Wert, den Sie pro Shadereigenschaft festlegen können. Es teilt dem Shader-Code mit, wie und von wo der Eigenschaftswert aus dem GPU-Speicher geladen werden soll. Die Bits 0–30 definieren den Offset der Eigenschaft innerhalb des BRG-Rohpuffers und Bit 31 gibt an, ob der Eigenschaftswert für alle Instanzen derselbe ist oder ob der Offset der Anfang eines Arrays mit einem Wert pro Instanz ist.
Die genaue Bedeutung der BRG-Metadaten hängt auch vom Shadereigenschaftstyp ab. Fassen wir alle Möglichkeiten zusammen:


Im Gegensatz zu Graphics.DrawMeshInstanced verwendet BRG einen persistenten GPU-Speicherpuffer. Angenommen, Sie haben 10 Würfelpositionen und -farben im Rohpuffer, aber nur die Würfel 0, 3 und 7 sind sichtbar. Sie möchten nur drei Würfel zeichnen, benötigen aber den Shader, um die Position und Farbe dieser Würfel richtig zu lesen. Zu diesem Zweck verwendet der BRG-Shader eine kleine zusätzliche Indirektion. Dieser Sichtbarkeitspuffer ist lediglich ein Array von „int“, das Sie beim Generieren von Zeichenbefehlen füllen.
In diesem Beispiel müssen Sie ein Array aus drei Ints mit {0,3,7} füllen und können dann einen BRG-Zeichenbefehl aus drei Instanzen generieren.

Der Shader-Code zum Abrufen der Eigenschaft „baseColor“ sieht folgendermaßen aus:
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Gehen Sie über das Beispiel hinaus: Da Sie jede Eigenschaft von SRP-Shadern (unbeleuchtet, einfach beleuchtet, beleuchtet) instanziieren können, verfügen alle Materialeigenschaften über einen „if metadata&(1<<31“-Zweig. Auch wenn Sie keinen benutzerdefinierten Glättewert pro Instanz benötigen, geht dies auf Kosten der Leistung. Im Beispiel möchten wir nur baseColor instanziieren. Sie können ein Shader-Diagramm erstellen, in dem nur die Farbe als BRG-instanziierbar definiert wird. Der generierte Code verfügt daher nur für die Farbeigenschaft über eine Indirektion zum umfangreichen Datenabruf. Auf einer Low-End-GPU sollten die Shader sogar noch etwas schneller laufen.
In unserem Spielbeispiel besteht der Boden aus 32x100 Zellen, also 3.200. Jede hat eine Position, Höhe und Farbe und die Zellen scrollen, während die Kamera statisch bleibt. Wenn eine Zeile aus der Ansicht herausgescrollt wird, fügen wir eine neue Zeile mit 32 Zellen ein.

Da sich zu jedem Zeitpunkt 3.200 Zellen befinden, ist eine Auslese nicht wirklich notwendig (alle Zellen befinden sich immer im Blickfeld der Kamera). Um jede Zelle zu positionieren, benötigen Sie eine obj2world-Matrix pro Zelle, die invertierte Matrix für die Beleuchtung und eine Farbe. Um den gesamten Boden zu rendern, verwenden wir einen einzelnen BRG-Zeichenbefehl.

Die Trümmer der Probe bestehen aus kleinen Würfeln, von denen jeder eine Position, Farbe und Drehung um seine vertikale Achse hat. Diese ist den Bodenzellen sehr ähnlich. Zu diesem Zweck haben wir BRG_Container.cs erstellt. Die Klasse verwaltet ein BRG-Objekt zum Rendern von Bodenzellen oder Explosionstrümmern. Die gesamte physikalische Animation und Interaktion erfolgt mit C#-Code unter Verwendung von BRG_Debris.cs.
Anders als bei Bodenzellen variiert die Schmutzmenge über den gesamten Rahmen hinweg. Bei der Initialisierung geben Sie die maximale Anzahl von Artikeln für BRG_Container an. In unserem Beispiel sind es 16.384 Trümmer (jede Explosion besteht aus 1.024 Trümmerwürfeln) und wir verwenden asynchrone Jobs, um Trümmer in einem Gravitationsfeld zu animieren. Wenn Trümmer auf eine Bodenzelle treffen, kommt es zu einer Wechselwirkung, indem sie sich in den Boden graben.
Um den GPU-Speicherplatz und die Bandbreite zu optimieren, verwendet BRG zum Speichern einer Matrix ein Float3x4 statt eines Float4x4. Bedenken Sie, dass eine BRG-Matrix im Rohpuffer 48 Bytes groß ist, nicht 64.

Der Rohpuffer sieht folgendermaßen aus:

Tipp: Die Rohpufferdaten von Trümmern ähneln den Bodendaten, da sie ebenfalls drei benutzerdefinierte Eigenschaften verwenden (obj2world, world2obj und Farbe). Die maximale Elementanzahl für Trümmer beträgt 16.384, was einen Rohpuffer von 112 x 16.384 Bytes oder 1,75 MiB bedeutet. Meistens werden nicht alle Trümmer gerendert, je nach der Anzahl der zu einem bestimmten Zeitpunkt vorhandenen Trümmerwürfel.
Wir haben einen GPU-Grafikpuffer von 358.400 Bytes. Da die Animation mit der CPU erfolgt, weisen wir auch einen ähnlichen Puffer im Systemspeicher zu (die CPU kann Daten mit voller Geschwindigkeit im Systemspeicher verarbeiten). Nennen wir diesen zweiten Puffer eine „Schattenkopie“ des GPU-Speichers. C#-Code animiert die Bodenzellen unter Verwendung von Sin und Debris aus der Schattenkopie. Wenn die Animation fertig ist, laden wir den Schattenkopie-Puffer mithilfe der GraphicsBuffer.SetData -API auf die GPU hoch.
Gehen Sie über das Beispiel hinaus: Die Optimierung des GPU-Renderings bedeutet oft eine Optimierung der Datenmenge. In unserem Beispiel verwenden wir Standard- und Stock-SRP-Shader. Aus diesem Grund haben wir drei Float4 für die Matrix und einen Float4 für die Farbe verwendet. Sie könnten noch weiter gehen und einen benutzerdefinierten Shader schreiben, um die Datengröße zu reduzieren, oder Sie könnten einen 32-Bit-Wert für die Bodenzellenhöhe verwenden.
Wenn Sie fortfahren möchten, berechnen Sie die Position der Zelle in der Welt anhand des Zellindex, berechnen Sie anschließend die Matrix und invertieren Sie die Matrix im Shader. Verwenden Sie schließlich eine 32-Bit-Ganzzahl, um die Farbe zu speichern. Laden Sie am Ende 8 Bytes pro Element hoch, statt 112. Dies führt zu einer 14-fachen Beschleunigung beim GPU-Datenupload. Dies würde eine Neuschreibung des Codes zum Abrufen der Shader bedeuten.
Jeder BRG-Zeichenbefehl benötigt eine MeshID, MaterialID und BatchID. Die ersten beiden sind leicht zu verstehen, aber BatchID ist subtiler. Stellen Sie sich BatchID als „eine Art Stapel“ vor. Um den Boden zu rendern, müssen Sie eine Art von Stapel registrieren, der wie folgt definiert ist:
1. Die Eigenschaft „unity_ObjectToWorld“ ist ein Array, das beim Offset 0 des BRG-Rohpuffers beginnt
2. Die Eigenschaft „unity_WorldToObject“ ist ein Array, das bei Offset 153.600 beginnt
3. Die Eigenschaft „_BaseColor“ ist ein Array, beginnend bei Offset 307.200
Der Code zum Registrieren dieser Art von Stapeln zum Zeitpunkt ihrer Erstellung sieht ungefähr wie folgt aus:
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Wir erhalten die m_batchId zum Zeitpunkt der Erstellung und können sie dann für jeden BRG-Zeichenbefehl verwenden (damit der Shader genau weiß, wie er Daten für diese Art von Batch abruft).
Tipp: BatchRendererGroup.AddBatch ist kein Rendering-Befehl. Es wird verwendet, um eine Art Stapelverarbeitung für zukünftige Rendering-Befehle zu registrieren.
Bisher können wir Bodenzellen animieren, den Systemspeicherpuffer der Schattenkopie auf die GPU hochladen und alle Zellen mit einem einzigen DrawCommand mit 3.200 Instanzen rendern.
Dies funktioniert auf den meisten Plattformen: DirectX, Vulkan, Metal und verschiedene Spielekonsolen, aber nicht auf GLES. Das Problem besteht darin, dass die meisten GLES 3.0-Geräte während der Vertex-Phase nicht auf SSBO zugreifen können (d. h. der Wert von GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS ist 0). Wenn die Grafik-API auf GLES eingestellt ist, verwendet BRG stattdessen einen konstanten Puffer oder UBO, um die Rohdaten zu speichern.
Dies fügt Einschränkungen hinzu: Ein konstanter Puffer kann eine beliebige Größe haben, aber wenn der Shader ausgeführt wird, ist immer nur ein kleiner Teil davon (ein Fenster) sichtbar. Die Fenstergröße hängt von der Hardware und dem Treiber ab, ein allgemein akzeptierter Wert ist jedoch 16 KiB.
Tipp: Im UBO-Modus sollten Sie immer die API BatchRendererGroup.GetConstantBufferMaxWindowSize() verwenden, um die richtige BRG-Fenstergröße zu erhalten.
Sehen wir uns an, wie sich unser Code ändert, wenn wir ihn auf GLES ausführen möchten. Bei Etagenzellen beträgt die Gesamtdatenmenge 350 KiB. Wir können kein einzelnes DrawInstanced(3.200) ausführen, da der Shader nicht in der Lage wäre, 350 KiB auf einmal zu sehen. Daher müssen wir die Daten innerhalb des UBO aufteilen, um die Anzahl der Instanzen pro Ziehung zu maximieren, sodass sie in einen 16-KiB-Block passen. Eine Bodenzelle ist 112 Byte groß (zwei Matrizen und eine Farbe), sodass Sie 16.384 geteilt durch 112 oder 146 Instanzen in einen 16-KiB-Block einfügen können. Um 3.200 Instanzen zu rendern, müssen wir 21 DrawInstanced(146) und ein letztes DrawInstanced(134) ausgeben.
Nun wird der 350-KiB-UBO in 22 Fensterblöcke mit jeweils 16 KiB aufgeteilt, wie folgt:

Tipp: Im UBO-Modus sollte jeder Fensterversatz an BatchRendererGroup.GetConstantBufferOffsetAlignment() ausgerichtet sein. Typische Ausrichtungswerte liegen im Bereich von 4 bis 256 Bytes.
In GLES müssen Sie aufgrund des UBO und der 16-KiB-Fenster 22 BatchIDs registrieren, um die Offsets jedes Fensters zu speichern. Der Initialisierungscode benötigt dann eine Schleife:
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Tipp: Um GLES (UBO) und andere Grafik-APIs (SSBO) im Spielbeispiel zu unterstützen, legt BRG_Container.cs beim Initialisieren einige Variablen fest. Im SSBO-Modus ist m_windowCount 1 und m_alignedGPUWindowSize ist die gesamte Puffergröße. Im UBO-Modus beträgt m_alignedGPUWindowSize 16 KiB und m_windowCount enthält die Anzahl der 16-KiB-Blöcke. (Der Wert von 16 KiB dient der Lesbarkeit. Verwenden Sie die API GetConstantBufferMaxWindowSize(), um den richtigen Wert zu erhalten.)
Sobald die CPU alle Matrizen und Farben im Systemspeicher aktualisiert hat, können Sie die Daten auf die GPU hochladen. Dies geschieht mit der Funktion BRG_Container.UploadGpuData . Aufgrund des SoA-Datenmodells können Sie keinen einzelnen Speicherblock hochladen. Für Schutt beträgt der Puffer 16.384 Gegenstände. Im GLES-Modus bedeutet das 113 Fenster mit jeweils 16 KiB, wenn 16.384 Trümmer auf dem Bildschirm sind.
Was aber, wenn sich in einem bestimmten Rahmen nur 5.300 Trümmerwürfel befinden? Da Sie 146 Elemente pro Fenster haben, bedeutet dies, dass die ersten 36 aufeinanderfolgenden 16-KiB-Fenster hochgeladen werden sollten, damit Sie ein einzelnes SetData (36 x 16 KiB) verwenden können. Im letzten Fenster sollten nur 44 Trümmerwürfel angezeigt werden. Um 44 Matrizen hochzuladen, invertieren Sie Matrizen und Farben und verwenden Sie drei SetData-Befehle. Ganz am Ende sollten vier SetData-Befehle ausgegeben werden.

Tipp: Selbst im SSBO-Modus sind drei SetData-Befehle erforderlich, wenn die Anzahl der Elemente unter dem Maximum liegt (z. B. 5.300 Trümmer über einem Maximum von 16.384). Einzelheiten zur Implementierung finden Sie unter BRG_Container.UploadGpuData(int instanceCount).
Der Haupteinstiegspunkt von BRG ist die Culling-Callback-Funktion, die Sie bei der Erstellung bereitstellen. Der Prototyp sieht so aus:
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Ihr Code in diesem Rückruf ist für zwei Dinge verantwortlich:
1. So generieren Sie alle Zeichenbefehle in der Ausgabestruktur BatchCullingOut
2. Um die in der schreibgeschützten Struktur BatchCullingContext bereitgestellten Informationen in Ihrem eigenen Culling-Code zu verwenden (oder nicht)
Notiz: Der Rückruf gibt einen JobHandle zurück, falls Sie einen asynchronen Job starten möchten, um diese Vorgänge auszuführen. Die Engine verwendet dies zum Synchronisieren an dem Punkt, an dem das Ergebnis benötigt wird, sodass Ihr Befehlsgenerierungscode den Hauptthread nicht blockiert.
BatchCullingContext enthält Informationen wie Kameramatrix, Kamera-Frustum-Pläne usw. Im Grunde müssen Sie alle Daten aussortieren und so weniger Zeichenbefehle generieren. Im Beispiel passen alle Objekte in die Kameraansicht (Bodenzellen und Schutt), daher ist die Verwendung von Culling-Code nicht erforderlich.
Die BatchCullingOutputDrawCommands- Struktur enthält verschiedene Daten, einschließlich Arrays. Es liegt in der Verantwortung des Benutzers, diesen Arrays nativen Speicher zuzuweisen. Die Engine ist dafür verantwortlich, den Speicher freizugeben, sobald die Daten verbraucht sind (Sie weisen ihn zu, Unity ist für die Freigabe verantwortlich). Die Speicherzuweisung sollte vom Typ Allocator.TempJob sein.
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Das erste Array, das Sie zuweisen sollten, ist das Sichtbarkeits-Int-Array. Da wir im Beispiel davon ausgehen, dass alles sichtbar ist, füllen wir das Sichtbarkeits-Int-Array einfach mit inkrementellen Werten wie {0,1,2,3,4,...}.
Ein BRG-Zeichenbefehl ist fast ein GPU DrawInstanced-Aufruf. Das wichtigste zuzuweisende und zu füllende Array ist der BatchDrawCommand. Nehmen wir an, dass sich im aktuellen Frame 4.737 Trümmerwürfel befinden.
m_maxInstancePerWindow beträgt im GLES-Modus 146. Sie können die Anzahl der Zeichenbefehle berechnen und den Puffer mithilfe des Höchstwerts von m_instanceCount geteilt durch m_maxInstancePerWindow zuordnen:
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Um die Duplizierung ähnlicher Parameter in mehreren Zeichenbefehlen zu vermeiden, verfügt BatchCullingOutputDrawCommands über ein Array von BatchDrawRange- Strukturen. Sie können verschiedene Parameter in BatchDrawRange.filterSettingseinrichten, wie etwa RenderingLayerMask, Empfangen von Schattenflags usw. Da alle Zeichenbefehle dieselben Rendering-Einstellungen verwenden, können Sie eine einzelne DrawCommandRange-Struktur zuweisen, die ab Zeichenbefehl 0 gilt und alle drawCommandCount-Befehle enthält.
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Füllen Sie dann die Zeichenbefehle aus. Jeder BatchDrawCommand enthält eine Mesh-ID, eine Batch-ID (um zu wissen, wie Metadaten verwendet werden) und eine Material-ID. Es enthält außerdem den Startversatz im Sichtbarkeits-Int-Array-Puffer. Da wir in unserem Kontext kein Frustum Culling benötigen, füllen wir das Sichtbarkeits-Array mit {0,1,2,3,...}. Dann beziehen sich alle Zeichenbefehle auf dieselbe {0,1,2,3,..}-Indirektion, sodass jeder BatchDrawCommand 0 als Start-Offset des Sichtbarkeits-Arrays verwendet. Der folgende Code weist alle benötigten Zeichenbefehle zu und füllt sie:
Unbekannter Blocktyp „codeBlock“, bitte geben Sie einen Serialisierer dafür in der Eigenschaft „serializers.types“ an
Das direkte Ansteuern von BatchRendererGroup erfordert einige Arbeit. Es funktioniert jedoch sofort, ohne dass benutzerdefinierte Shader oder zusätzliche Pakete erforderlich sind. In manchen Situationen, beispielsweise wenn viele CPU-simulierte Objekte mit benutzerdefinierten instanziierten Eigenschaften gerendert werden müssen, ist BatchRendererGroup Ihr bester Freund.
Sie können das Projekt aus diesem Repositoryherunterladen.
Sie können auch die Foren besuchen, um weitere Details darüber zu diskutieren, wie wir das C#-Jobsystem und den Burst-Compiler verwendet haben, um alle Animationen und Interaktionen mit voller Geschwindigkeit zu verarbeiten, selbst auf einer Low-End-CPU.
