Mit Unity DOTS können Entwickler das volle Potenzial moderner Prozessoren nutzen und hochoptimierte, effiziente Spiele liefern – und wir denken, dass es sich lohnt, darauf zu achten.
Es ist über fünf Jahre her, seit Unity erstmals die Entwicklung seines Data-Oriented Technology Stack (DOTS) angekündigt hat. Jetzt, mit der Veröffentlichung von Unity 2023.3.0f1, haben wir endlich eine offizielle Veröffentlichung gesehen. Aber warum ist Unity DOTS für die Spieleentwicklungsbranche so wichtig und welche Vorteile bietet diese Technologie?
Hallo zusammen! Mein Name ist Denis Kondratev und ich bin Unity- Entwickler bei MY.GAMES. Wenn Sie schon immer wissen wollten, was Unity DOTS ist und ob es sich lohnt, es zu erkunden, ist dies die perfekte Gelegenheit, sich mit diesem faszinierenden Thema zu befassen, und in diesem Artikel werden wir genau das tun.
Im Kern implementiert DOTS das Architekturmuster Entity Component System (ECS). Um dieses Konzept zu vereinfachen, beschreiben wir es folgendermaßen: ECS basiert auf drei grundlegenden Elementen: Entitäten, Komponenten und Systemen.
Entitäten allein verfügen über keine inhärente Funktionalität oder Beschreibung. Stattdessen dienen sie als Container für verschiedene Komponenten, die ihnen spezifische Eigenschaften für Spiellogik, Objektwiedergabe, Soundeffekte und mehr verleihen.
Komponenten wiederum gibt es in unterschiedlichen Typen und speichern lediglich Daten ohne eigene unabhängige Verarbeitungsfähigkeiten.
Abgerundet wird das ECS-Framework durch Systeme , die Komponenten verarbeiten, die Erstellung und Zerstörung von Entitäten verwalten und das Hinzufügen oder Entfernen von Komponenten verwalten.
Wenn Sie beispielsweise ein „Space Shooter“-Spiel erstellen, wird der Spielplatz mehrere Objekte enthalten: das Raumschiff des Spielers, Feinde, Asteroiden, Beute und vieles mehr.
Alle diese Objekte gelten als eigenständige Einheiten ohne besondere Merkmale. Indem wir ihnen jedoch unterschiedliche Komponenten zuweisen, können wir ihnen einzigartige Eigenschaften verleihen.
Um zu demonstrieren, dass alle diese Objekte Positionen auf dem Spielfeld besitzen, können wir eine Positionskomponente erstellen, die die Koordinaten des Objekts enthält. Darüber hinaus können wir für das Raumschiff, die Feinde und die Asteroiden des Spielers Gesundheitskomponenten integrieren; Das System, das für die Behandlung von Objektkollisionen verantwortlich ist, regelt den Zustand dieser Entitäten. Darüber hinaus können wir den Feinden eine Feindtypkomponente hinzufügen, sodass das Feindkontrollsystem ihr Verhalten basierend auf dem zugewiesenen Typ steuern kann.
Während diese Erklärung einen vereinfachten, rudimentären Überblick bietet, ist die Realität etwas komplexer. Dennoch vertraue ich darauf, dass das Grundkonzept von ECS klar ist. Nachdem dies geklärt ist, wollen wir uns mit den Vorteilen dieses Ansatzes befassen.
Einer der Hauptvorteile des Entity Component System (ECS)-Ansatzes ist das architektonische Design, das er fördert. Die objektorientierte Programmierung (OOP) weist ein bedeutendes Erbe mit Mustern wie Vererbung und Kapselung auf, und selbst erfahrene Programmierer können in der Hitze der Entwicklung Architekturfehler machen, die in langfristigen Projekten zu Refactoring oder verwickelter Logik führen.
Im Gegensatz dazu bietet ECS eine einfache und intuitive Architektur. Alles zerfällt auf natürliche Weise in isolierte Komponenten und Systeme, was das Verständnis und die Entwicklung mit diesem Ansatz erleichtert; Selbst unerfahrene Entwickler verstehen diesen Ansatz schnell und mit minimalen Fehlern.
ECS verfolgt einen zusammengesetzten Ansatz, bei dem anstelle komplexer Vererbungshierarchien isolierte Komponenten und Verhaltenssysteme erstellt werden. Diese Komponenten und Systeme können einfach hinzugefügt oder entfernt werden, was flexible Änderungen an den Eigenschaften und dem Verhalten von Entitäten ermöglicht – dieser Ansatz verbessert die Wiederverwendbarkeit von Code erheblich.
Ein weiterer wesentlicher Vorteil von ECS ist die Leistungsoptimierung. In ECS werden Daten zusammenhängend und optimiert im Speicher gespeichert, wobei identische Datentypen nahe beieinander platziert werden. Dies optimiert den Datenzugriff, reduziert Cache-Fehler und verbessert die Speicherzugriffsmuster. Darüber hinaus lassen sich Systeme, die aus separaten Datenblöcken bestehen, einfacher über verschiedene Prozesse hinweg parallelisieren, was zu außergewöhnlichen Leistungssteigerungen im Vergleich zu herkömmlichen Ansätzen führt.
Unity DOTS umfasst eine Reihe von Technologien, die von Unity Technologies bereitgestellt werden und das ECS-Konzept in Unity implementieren. Es enthält mehrere Pakete, die verschiedene Aspekte der Spieleentwicklung verbessern sollen. Lassen Sie uns jetzt einige davon behandeln.
Der Kern von DOTS ist das Entities- Paket, das den Übergang von bekannten MonoBehaviours und GameObjects zum Entity Component System-Ansatz erleichtert. Dieses Paket bildet die Grundlage der DOTS-basierten Entwicklung.
Das Unity Physics- Paket führt einen neuen Ansatz für den Umgang mit Physik in Spielen ein und erreicht durch parallelisierte Berechnungen eine bemerkenswerte Geschwindigkeit.
Darüber hinaus ermöglicht das Havok Physics for Unity- Paket die Integration mit der modernen Havok Physics-Engine. Diese Engine bietet leistungsstarke Kollisionserkennung und physikalische Simulation und unterstützt beliebte Spiele wie The Legend of Zelda: Breath of the Wild, Doom Eternal, Death Stranding, Mortal Kombat 11 und mehr.
Das Entities Graphics- Paket konzentriert sich auf das Rendern in DOTS. Es ermöglicht eine effiziente Erfassung von Rendering-Daten und arbeitet nahtlos mit vorhandenen Render-Pipelines wie der Universal Render Pipeline (URP) oder der High Definition Render Pipeline (HDRP) zusammen.
Darüber hinaus hat Unity auch aktiv eine Netzwerktechnologie namens Netcode entwickelt. Es umfasst Pakete wie Unity Transport für die Entwicklung von Multiplayer-Spielen auf niedriger Ebene, Netcode für GameObjects für traditionelle Ansätze und das bemerkenswerte Unity Netcode für Entities- Paket, das sich an den DOTS-Prinzipien orientiert. Diese Pakete sind relativ neu und werden in Zukunft weiterentwickelt.
Mehrere mit DOTS eng verwandte Technologien können innerhalb des DOTS-Frameworks und darüber hinaus verwendet werden. Das Job System- Paket bietet eine praktische Möglichkeit, Code mit parallelen Berechnungen zu schreiben. Dabei geht es darum, die Arbeit in kleine Blöcke, sogenannte Jobs, aufzuteilen, die Berechnungen anhand ihrer eigenen Daten durchführen. Das Jobsystem verteilt diese Jobs gleichmäßig auf Threads, um eine effiziente Ausführung zu gewährleisten.
Um die Codesicherheit zu gewährleisten, unterstützt das Jobsystem die Verarbeitung blitbarer Datentypen. Blittable-Datentypen haben im verwalteten und nicht verwalteten Speicher die gleiche Darstellung und erfordern keine Konvertierung, wenn sie zwischen verwaltetem und nicht verwaltetem Code übergeben werden. Beispiele für blitbare Typen sind Byte, Sbyte, Short, Ushort, Int, Uint, Long, Ulong, Float, Double, IntPtr und UIntPtr. Als blitbar gelten auch eindimensionale Arrays blitbarer Grundtypen und Strukturen, die ausschließlich blitbare Typen enthalten.
Typen, die ein variables Array blitbarer Typen enthalten, gelten jedoch selbst nicht als blitbar. Um dieser Einschränkung zu begegnen, hat Unity das Collections- Paket entwickelt, das eine Reihe nicht verwalteter Datenstrukturen zur Verwendung in Jobs bereitstellt. Diese Sammlungen sind strukturiert und speichern Daten mithilfe von Unity-Mechanismen im nicht verwalteten Speicher. Es liegt in der Verantwortung des Entwicklers, die Zuordnung dieser Sammlungen mithilfe der Disposal()-Methode aufzuheben.
Ein weiteres wichtiges Paket ist der Burst Compiler , der mit dem Job System zur Generierung hochoptimierten Codes verwendet werden kann. Obwohl es gewisse Einschränkungen bei der Codenutzung gibt, bietet der Burst-Compiler eine deutliche Leistungssteigerung.
Wie bereits erwähnt, sind Job System und Burst Compiler keine direkten Bestandteile von DOTS, bieten aber wertvolle Hilfe bei der Programmierung effizienter und schneller paralleler Berechnungen. Lassen Sie uns ihre Fähigkeiten anhand eines praktischen Beispiels testen: der Implementierung
private void SimulateStep() { Profiler.BeginSample(nameof(SimulateStep)); for (var i = 0; i < width; i++) { for (var j = 0; j < height; j++) { var aliveNeighbours = CountAliveNeighbours(i, j); var index = i * height + j; var isAlive = aliveNeighbours switch { 2 => _cellStates[index], 3 => true, _ => false }; _tempResults[index] = isAlive; } } _tempResults.CopyTo(_cellStates); Profiler.EndSample(); } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= height) continue; if (_cellStates[i * width + j]) { count++; } } } return count; }
Ich habe dem Profiler Markierungen hinzugefügt, um die für die Berechnungen benötigte Zeit zu messen. Die Zustände der Zellen werden in einem eindimensionalen Array namens _cellStates gespeichert. Wir schreiben die temporären Ergebnisse zunächst in _tempResults und kopieren sie nach Abschluss der Berechnungen zurück nach _cellStates . Dieser Ansatz ist notwendig, da das direkte Schreiben des Endergebnisses in _cellStates Auswirkungen auf nachfolgende Berechnungen haben würde.
Ich habe ein Feld mit 1000 x 1000 Zellen erstellt und das Programm ausgeführt, um die Leistung zu messen. Hier sind die Ergebnisse:
Wie aus den Ergebnissen hervorgeht, dauerten die Berechnungen 380 ms.
Wenden wir nun das Jobsystem und den Burst-Compiler an, um die Leistung zu verbessern. Zuerst erstellen wir den Job, der für die Ausführung des Conway's Game of Life-Algorithmus verantwortlich ist.
public struct SimulationJob : IJobParallelFor { public int Width; public int Height; [ReadOnly] public NativeArray<bool> CellStates; [WriteOnly] public NativeArray<bool> TempResults; public void Execute(int index) { var i = index / Height; var j = index % Height; var aliveNeighbours = CountAliveNeighbours(i, j); var isAlive = aliveNeighbours switch { 2 => CellStates[index], 3 => true, _ => false }; TempResults[index] = isAlive; } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= Width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= Height) continue; if (CellStates[i * Width + j]) { count++; } } } return count; } }
Ich habe dem Feld CellStates das Attribut [ReadOnly] zugewiesen, das von jedem Thread aus uneingeschränkten Zugriff auf alle Werte des Arrays ermöglicht. Für das TempResults- Feld, das über das Attribut [WriteOnly] verfügt, kann das Schreiben jedoch nur über den Index erfolgen, der in der Methode Execute(int index) angegeben ist. Beim Versuch, einen Wert in einen anderen Index zu schreiben, wird eine Warnung generiert. Dies gewährleistet die Datensicherheit beim Arbeiten im Multithread-Modus.
Starten wir nun mit dem regulären Code unseren Job:
private void SimulateStepWithJob() { Profiler.BeginSample(nameof(SimulateStepWithJob)); var job = new SimulationJob { Width = width, Height = height, CellStates = _cellStates, TempResults = _tempResults }; var jobHandler = job.Schedule(width * height, 4); jobHandler.Complete(); job.TempResults.CopyTo(_cellStates); Profiler.EndSample(); }
Nachdem wir alle notwendigen Daten kopiert haben, planen wir die Ausführung des Jobs mit der Methode Schedule() . Es ist wichtig zu beachten, dass diese Planung die Berechnungen nicht sofort ausführt: Diese Aktionen werden vom Hauptthread initiiert und die Ausführung erfolgt durch Worker, die auf verschiedene Threads verteilt sind. Um auf den Abschluss des Jobs zu warten, verwenden wir jobHandler.Complete() . Erst dann können wir das erhaltene Ergebnis zurück nach _cellStates kopieren.
Lass uns die Geschwindigkeit messen:
Die Ausführungsgeschwindigkeit hat sich fast verzehnfacht und die Ausführungszeit beträgt nun etwa 42 ms. Im Profiler-Fenster können wir sehen, dass die Arbeitslast auf 17 Arbeiter verteilt wurde. Diese Zahl ist etwas geringer als die Anzahl der Prozessor-Threads in der Testumgebung, bei der es sich um einen Intel® Core™ i9-10900 mit 10 Kernen und 20 Threads handelt. Während die Ergebnisse bei Prozessoren mit weniger Kernen variieren können, können wir die volle Ausnutzung der Prozessorleistung sicherstellen.
Aber das ist noch nicht alles – es ist an der Zeit, den Burst Compiler zu nutzen, der eine erhebliche Codeoptimierung bietet, aber mit gewissen Einschränkungen verbunden ist. Um den Burst Compiler zu aktivieren, fügen Sie einfach das Attribut [BurstCompile] zum SimulationJob hinzu.
[BurstCompile] public struct SimulationJob : IJobParallelFor { ... }
Lass uns noch einmal messen:
Die Ergebnisse übertreffen selbst die optimistischsten Erwartungen: Die Geschwindigkeit hat sich im Vergleich zum ursprünglichen Ergebnis fast um das 200-fache erhöht. Nun beträgt die Rechenzeit für 1 Million Zellen nicht mehr als 2 ms. Im Profiler werden die Teile, die von dem mit dem Burst-Compiler kompilierten Code ausgeführt werden, grün hervorgehoben.
Während die Verwendung von Multithread-Berechnungen möglicherweise nicht immer notwendig ist und die Verwendung von Burst Compiler möglicherweise nicht immer möglich ist, können wir einen allgemeinen Trend in der Prozessorentwicklung hin zu Multi-Core-Architekturen beobachten. Das bedeutet, dass wir bereit sein sollten, ihre volle Kraft zu nutzen. ECS und insbesondere Unity DOTS passen perfekt zu diesem Paradigma.
Ich glaube, dass Unity DOTS zumindest Aufmerksamkeit verdient. Auch wenn es nicht in jedem Fall die beste Lösung ist, kann ECS sich in vielen Spielen bewähren.
Das Unity DOTS-Framework bietet mit seinem datenorientierten und multithreadigen Ansatz enormes Potenzial zur Leistungsoptimierung in Unity-Spielen. Durch die Übernahme der Entity Component System-Architektur und die Nutzung von Technologien wie dem Job System und dem Burst Compiler können Entwickler neue Leistungs- und Skalierbarkeitsniveaus erschließen.
Da sich die Spieleentwicklung und die Hardware weiterentwickeln, wird die Nutzung von Unity DOTS immer wertvoller. Es ermöglicht Entwicklern, das volle Potenzial moderner Prozessoren auszuschöpfen und hochoptimierte und effiziente Spiele zu liefern. Obwohl Unity DOTS möglicherweise nicht für jedes Projekt die ideale Lösung ist, ist es zweifellos vielversprechend für diejenigen, die eine leistungsorientierte Entwicklung und Skalierbarkeit suchen.
Unity DOTS ist ein leistungsstarkes Framework, das Spieleentwicklern erhebliche Vorteile bieten kann, indem es die Leistung steigert, parallele Berechnungen ermöglicht und die Zukunft der Multi-Core-Verarbeitung ermöglicht. Es lohnt sich, die Einführung zu prüfen und in Erwägung zu ziehen, um moderne Hardware voll auszunutzen und die Leistung von Unity-Spielen zu optimieren.