Mit Unity3D wurde es mit der Veröffentlichung der Version 2018 möglich, das native (für Unity) ECS-System zu verwenden, das mit Multithreading in Form eines Jobsystems ausgestattet ist. Es gibt nicht viele Materialien im Internet (einige Projekte von Unity Technologies selbst und einige Schulungsvideos auf YouTube). Ich habe versucht, den Umfang und die Bequemlichkeit von ECS zu erkennen und ein kleines Projekt zu erstellen, das nicht aus Würfeln und Knöpfen besteht. Zuvor hatte ich keine Erfahrung mit dem Entwerfen von ECS, daher dauerte es zwei Tage, um Materialien zu studieren und das Denken mit OOP wieder aufzubauen, einen Tag, um sich an dem Ansatz zu erfreuen, und ein oder zwei Tage, um ein Projekt zu entwickeln, die Einheit zu bekämpfen, Haare herauszuziehen und Proben zu rauchen . Der Artikel enthält ein bisschen Theorie und ein kleines Beispielprojekt.
Die Bedeutung von ECS ist recht einfach - eine Entität (
Entität ) mit ihren Komponenten (
Komponente ), die vom System (
System ) verarbeitet werden.
Essenz
Die Entität hat keine Logik und speichert nur Komponenten (sehr ähnlich zu GameObject im alten CPC-Ansatz). In Unity ECS existiert hierfür die Entity-Klasse.
Komponente
Komponenten speichern nur Daten und enthalten manchmal überhaupt nichts und sind ein einfacher Marker für die Verarbeitung durch das System. Aber sie haben keine Logik. Von ComponentDataWrapper geerbt. Es kann von einem anderen Thread verarbeitet werden (aber es gibt eine Nuance).
Das System
Systeme sind für die Verarbeitung von Komponenten verantwortlich. Bei der Eingabe erhalten sie von Unity eine Liste der verarbeiteten Komponenten für die angegebenen Typen, und bei überladenen Methoden (Analoga von Update, Start, OnDestroy) tritt die Magie der Spielmechanik auf. Von ComponentSystem oder JobComponentSystem geerbt.
Jobsystem
Die Mechanik von Systemen, die die parallele Verarbeitung von Bauteilen ermöglichen. Im OnUpdate-System wird eine Jobstruktur erstellt und der Verarbeitung hinzugefügt. In einem Moment der Langeweile und der freien Ressourcen wird Unity die Ergebnisse verarbeiten und auf die Komponenten anwenden.
Multithreading und Einheit 2018
Alle Job System-Arbeiten finden in anderen Threads statt, und Standardkomponenten (Transform, Rigidbody usw.) können in keinem Thread außer dem Haupt-Thread geändert werden. Daher enthält das Standardpaket kompatible Ersatzkomponenten - Positionskomponente, Rotationskomponente, Renderer-Komponente für Netzinstanzen.
Gleiches gilt für Standardstrukturen wie Vector3 oder Quaternion. Die Komponenten für die Parallelisierung verwenden nur die einfachsten Datentypen (float3, float4, das ist alles, Grafikprogrammierer werden sich freuen), die dem Unity.Mathematics-Namespace hinzugefügt wurden. Es gibt auch eine Mathematikklasse, um sie zu verarbeiten. Keine Strings, keine Referenztypen, nur Hardcore.
"Zeig mir den Code"
Also Zeit, etwas zu bewegen!
Erstellen Sie eine Komponente, die den Geschwindigkeitswert speichert und gleichzeitig eine der Markierungen für das System ist, das Objekte bewegt. Mit dem Attribut Serializable können Sie den Wert im Inspektor festlegen und verfolgen.
Speedcompponent[Serializable] public struct SpeedData : IComponentData { public int Value; } public class SpeedComponent : ComponentDataWrapper<SpeedData> {}
Mit dem Inject-Attribut erhält das System eine Struktur, die nur Komponenten der Entitäten enthält, auf denen alle drei Komponenten vorhanden sind. Wenn also eine Entität über PositionComponent- und SpeedComponent-Komponenten verfügt, jedoch nicht über RotationComponent, wird diese Entität nicht zur Struktur hinzugefügt, die in das System eintritt. Somit ist es möglich, Entitäten nach dem Vorhandensein einer Komponente zu filtern.
Bewegungssystem public class MovementSystem : ComponentSystem { public struct ShipsPositions { public int Length; public ComponentDataArray<Position> Positions; public ComponentDataArray<Rotation> Rotations; public ComponentDataArray<SpeedData> Speeds; } [Inject] ShipsPositions _shipsMovementData; protected override void OnUpdate() { for(int i = 0; i < _shipsMovementData.Length; i++) { _shipsMovementData.Positions[i] = new Position(_shipsMovementData.Positions[i].Value + math.forward(_shipsMovementData.Rotations[i].Value) * Time.deltaTime * _shipsMovementData.Speeds[i].Value); } } }
Jetzt bewegen sich alle Objekte, die diese drei Komponenten enthalten, mit einer bestimmten Geschwindigkeit vorwärts.
Es war einfach. Obwohl es einen Tag gedauert hat, über ECS nachzudenken.
Aber hör auf. Wo ist das Jobsystem hier?
Tatsache ist, dass nichts kaputt genug ist, um Multithreading zu verwenden. Zeit zu brechen!
Ich habe aus den Proben das System gezogen, aus dem die Fertighäuser hervorgehen. Von interessant - hier ist ein Stück Code:
Spawner EntityManager.Instantiate(prefab, entities); for (int i = 0; i < count; i++) { var position = new Position { Value = spawnPositions[i] }; EntityManager.SetComponentData(entities[i], position); EntityManager.SetComponentData(entities[i], new SpeedData { Value = Random.Range(15, 25) }); }
Lassen Sie uns also 1000 Objekte platzieren. Immer noch zu gut, um Netze auf der GPU zu instanziieren. 5000 - auch ca. Ich werde zeigen, was mit 50.000 Objekten passiert.
Der Entity Debugger wurde in Unity angezeigt und zeigt an, wie viele ms jedes System benötigt. Systeme können direkt zur Laufzeit ein- und ausgeschaltet werden, um zu sehen, welche Objekte sie im Allgemeinen verarbeiten, was unersetzlich ist.
Holen Sie sich so einen Raumschiffball Das Tool zeichnet mit einer Geschwindigkeit von 15 fps auf, sodass der springende Punkt in den Zahlen in der Liste der Systeme liegt. Unser MovementSystem versucht, alle 50.000 Objekte in jedem Frame zu verschieben, und zwar im Durchschnitt in 60 ms. Jetzt ist das Spiel also kaputt genug für die Optimierung.
Wir befestigen das JobSystem am Bewegungssystem.
Modifiziertes Bewegungssystem public class MovementSystem : JobComponentSystem { [ComputeJobOptimization] struct MoveShipJob : IJobProcessComponentData<Position, Rotation, SpeedData> { public float dt; public void Execute(ref Position position, ref Rotation rotation, ref SpeedData speed) { position.Value += math.forward(rotation.Value) * dt * speed.Value; } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new MoveShipJob { dt = Time.deltaTime }; return job.Schedule(this, 1, inputDeps); } }
Jetzt erbt das System von JobComponentSystem und erstellt in jedem Frame einen speziellen Handler, an den Unity die gleichen 3 Komponenten und deltaTime vom System überträgt.
Starten Sie das Raumschiff erneut 0,15 ms (0,4 am Peak, ja) gegenüber 50-70! 50.000 Objekte! Ich gab diese Zahlen in den Taschenrechner ein, als Antwort zeigte er ein glückliches Gesicht.
Management
Sie können endlos einen fliegenden Ball betrachten oder zwischen den Schiffen fliegen.
Benötigen Sie ein Rollsystem.
Die Rotationskomponente befindet sich bereits im Fertighaus. Erstellen Sie eine Komponente zum Speichern von Steuerelementen.
Steuerkomponente [Serializable] public struct RotationControlData : IComponentData { public float roll; public float pitch; public float yaw; } public class ControlComponent : ComponentDataWrapper<RotationControlData>{}
Wir brauchen auch eine Spielerkomponente (obwohl es kein Problem ist, alle 50.000 Schiffe gleichzeitig zu steuern).
PlayerComponent public struct PlayerData : IComponentData { } public class PlayerComponent : ComponentDataWrapper<PlayerData> { }
Und sofort ein User Input Reader.
UserControlSystem public class UserControlSystem : ComponentSystem { public struct InputPlayerData { public int Length; [ReadOnly] public ComponentDataArray<PlayerData> Data; public ComponentDataArray<RotationControlData> Controls; } [Inject] InputPlayerData _playerData; protected override void OnUpdate() { for (int i = 0; i < _playerData.Length; i++) { _playerData.Controls[i] = new RotationControlData { roll = Input.GetAxis("Horizontal"), pitch = Input.GetAxis("Vertical"), yaw = Input.GetKey(KeyCode.Q) ? -1 : Input.GetKey(KeyCode.E) ? 1 : 0 }; } } }
Anstelle des Standardeingangs kann es jedes Lieblingsfahrrad oder jede KI geben.
Und schließlich die Verarbeitungssteuerung und die Wende selbst. Ich war mit der Tatsache konfrontiert, dass math.euler noch nicht implementiert wurde, und so rettete mich ein schneller Angriff auf Wikipedia vor der Konvertierung von Eulers Ecken in das Quaternion.
ProcessRotationInputSystem public class ProcessRotationInputSystem : JobComponentSystem { struct LocalRotationSpeedGroup { public ComponentDataArray<Rotation> rotations; [ReadOnly] public ComponentDataArray<RotationSpeedData> rotationSpeeds; [ReadOnly] public ComponentDataArray<RotationControlData> controlData; public int Length; } [Inject] private LocalRotationSpeedGroup _rotationGroup; [ComputeJobOptimization] struct RotateJob : IJobParallelFor { public ComponentDataArray<Rotation> rotations; [ReadOnly] public ComponentDataArray<RotationSpeedData> rotationSpeeds; [ReadOnly] public ComponentDataArray<RotationControlData> controlData; public float dt; public void Execute(int i) { var speed = rotationSpeeds[i].Value; if (speed > 0.0f) { quaternion nRotation = math.normalize(rotations[i].Value); float yaw = controlData[i].yaw * speed * dt; float pitch = controlData[i].pitch * speed * dt; float roll = -controlData[i].roll * speed * dt; quaternion result = math.mul(nRotation, Euler(pitch, roll, yaw)); rotations[i] = new Rotation { Value = result }; } } quaternion Euler(float roll, float yaw, float pitch) { float cy = math.cos(yaw * 0.5f); float sy = math.sin(yaw * 0.5f); float cr = math.cos(roll * 0.5f); float sr = math.sin(roll * 0.5f); float cp = math.cos(pitch * 0.5f); float sp = math.sin(pitch * 0.5f); float qw = cy * cr * cp + sy * sr * sp; float qx = cy * sr * cp - sy * cr * sp; float qy = cy * cr * sp + sy * sr * cp; float qz = sy * cr * cp - cy * sr * sp; return new quaternion(qx, qy, qz, qw); } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new RotateJob { rotations = _rotationGroup.rotations, rotationSpeeds = _rotationGroup.rotationSpeeds, controlData = _rotationGroup.controlData, dt = Time.deltaTime }; return job.Schedule(_rotationGroup.Length, 64, inputDeps); } }
Sie werden sich wahrscheinlich fragen, warum Sie nicht wie in MovementSystem einfach 3 Komponenten gleichzeitig an Job übergeben können. Weil. Ich hatte lange damit zu kämpfen, aber ich weiß nicht, warum es so nicht funktioniert. In Beispielen werden Turns über ComponentDataArray implementiert, aber wir werden nicht von den Kanonen zurücktreten.
Wir werfen das Fertighaus auf die Bühne, hängen die Komponenten auf, binden die Kamera, legen langweilige Tapeten auf und gehen!

Fazit
Die Jungs von Unity Technologies haben sich in die richtige Richtung des Multithreading bewegt. Das Job-System selbst ist noch feucht (die Alpha-Version ist es immerhin), aber es ist ziemlich brauchbar und beschleunigt sich jetzt. Leider sind die Standardkomponenten nicht mit dem Job-System kompatibel (aber nicht separat mit dem ECS!). Sie müssen also Krücken formen, um dies zu umgehen. Beispielsweise implementiert eine Person aus dem Unity-Forum ihr physisches System für die GPU und macht ähnliche Fortschritte.
ECS mit Unity wurde schon früher verwendet, es gibt mehrere wohlhabende Analoga, zum Beispiel
einen Artikel mit einem Überblick über die bekanntesten. Es beschreibt auch die Vor- und Nachteile dieses Architekturansatzes.
Von mir selbst kann ich ein Plus wie die Reinheit des Codes hinzufügen. Ich habe zunächst versucht, Bewegung in einem System zu implementieren. Die Anzahl der Abhängigkeitskomponenten wuchs schnell und ich musste den Code in kleine und bequeme Systeme aufteilen. Und sie können problemlos in einem anderen Projekt wiederverwendet werden.
Der Projektcode ist hier:
GitHub