Avec Unity3D, avec la sortie de la version 2018, il est devenu possible d'utiliser le système ECS natif (pour Unity), agrémenté de multi-threading sous la forme d'un Job System. Il n'y a pas beaucoup de matériel sur Internet (quelques projets d'Unity Technologies eux-mêmes et quelques vidéos de formation sur YouTube). J'ai essayé de réaliser l'échelle et la commodité d'ECS, en réalisant un petit projet sans cubes ni boutons. Avant cela, je n'avais aucune expérience dans la conception d'ECS, il a donc fallu deux jours pour étudier les matériaux et recentrer la réflexion avec OOP, une journée s'est admirée pour l'approche et deux ou deux jours pour développer un projet, combattre Unity, retirer les cheveux et les échantillons de fumée . L'article contient un peu de théorie et un petit exemple de projet.
La signification d'ECS est assez simple - une entité (
Entité ) avec ses composants (
Composant ), qui sont traités par le système (
Système ).
Essence
L'entité n'a pas de logique et ne stocke que des composants (très similaire à GameObject dans l'ancienne approche CPC). Dans Unity ECS, la classe Entity existe pour cela.
Composant
Les composants ne stockent que des données et ne contiennent parfois rien du tout et sont un simple marqueur pour le traitement par le système. Mais ils n'ont aucune logique. Hérité de ComponentDataWrapper. Il peut être traité par un autre thread (mais il y a une nuance).
Le système
Les systèmes sont responsables du traitement des composants. En entrée, ils reçoivent d'Unity une liste de composants traités pour les types donnés, et dans les méthodes surchargées (analogues de Update, Start, OnDestroy), la magie des mécanismes de jeu se produit. Hérité de ComponentSystem ou JobComponentSystem.
Système d'emploi
La mécanique des systèmes qui permet le traitement parallèle des composants. Dans le système OnUpdate, une structure de Job est créée et ajoutée au traitement. Dans un moment d'ennui et de ressources gratuites, Unity traitera et appliquera les résultats aux composants.
Multithreading et Unity 2018
Tous les travaux du Job System ont lieu dans d'autres threads et les composants standard (Transform, Rigidbody, etc.) ne peuvent pas être modifiés dans n'importe quel autre thread que le principal. Par conséquent, dans le package standard, il existe des composants de «remplacement» compatibles: composant de position, composant de rotation, composant de rendu d'instance de maillage.
La même chose s'applique aux structures standard comme Vector3 ou Quaternion. Les composants de parallélisation n'utilisent que les types de données les plus simples (float3, float4, c'est tout, les programmeurs graphiques seront satisfaits) ajoutés à l'espace de noms Unity.Mathematics, il existe également une classe mathématique pour les traiter. Pas de cordes, pas de types de référence, seulement du hardcore.
"Montrez-moi le code"
Alors, il est temps de bouger quelque chose!
Créez un composant qui stocke la valeur de vitesse et est également l'un des marqueurs du système qui déplace les objets. L'attribut Sérialisable vous permet de définir et de suivre la valeur dans l'inspecteur.
Speedcompponent[Serializable] public struct SpeedData : IComponentData { public int Value; } public class SpeedComponent : ComponentDataWrapper<SpeedData> {}
À l'aide de l'attribut Inject, le système obtient une structure contenant
uniquement les composants des entités sur lesquelles les trois composants sont présents. Ainsi, si une entité possède des composants PositionComponent et SpeedComponent, mais pas RotationComponent, cette entité ne sera pas ajoutée à la structure entrant dans le système. Ainsi, il est possible de filtrer les entités par la présence d'un composant.
MovementSystem 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); } } }
Désormais, tous les objets contenant ces trois composants avanceront à une vitesse donnée.
C'était facile. Bien qu'il ait fallu un jour pour penser à ECS.
Mais arrête. Où est le Job System ici?
Le fait est que rien n'est suffisamment cassé pour utiliser le multithreading. Il est temps de se casser!
J'ai extrait des échantillons le système qui donne naissance aux préfabriqués. D'intéressant - voici un morceau de code:
Générateur 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) }); }
Alors, mettons 1000 objets. Encore trop bon pour instancier des maillages sur le GPU. 5000 - également env. Je vais montrer ce qui se passe avec 50 000 objets.
Le débogueur d'entité est apparu dans Unity, montrant combien de ms chaque système prend. Les systèmes peuvent être activés / désactivés directement lors de l'exécution, pour voir quels objets ils traitent, en général, une chose irremplaçable.
Obtenez une telle balle de vaisseau spatial L'outil enregistre à une vitesse de 15 ips, donc tout le point est dans les nombres dans la liste des systèmes. Le nôtre, MovementSystem, essaie de déplacer les 50 000 objets dans chaque image, et le fait en moyenne en 60 ms. Donc, maintenant le jeu est suffisamment cassé pour être optimisé.
Nous fixons le JobSystem au système de mouvement.
Système de mouvement modifié 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); } }
Maintenant, le système hérite de JobComponentSystem et dans chaque trame crée un gestionnaire spécial vers lequel Unity transfère les mêmes 3 composants et deltaTime du système.
Lancez à nouveau le vaisseau spatial 0,15 ms (0,4 au pic, oui) contre 50-70! 50 mille objets! J'ai entré ces chiffres dans la calculatrice, en réponse, il a montré un visage heureux.
La gestion
Vous pouvez sans cesse regarder un ballon volant, ou vous pouvez voler parmi les navires.
Besoin d'un système de roulage.
Le composant Rotation est déjà sur le préfabriqué, créez un composant pour stocker les contrôles.
Composant de contrôle [Serializable] public struct RotationControlData : IComponentData { public float roll; public float pitch; public float yaw; } public class ControlComponent : ComponentDataWrapper<RotationControlData>{}
Nous avons également besoin d'un composant joueur (bien que ce ne soit pas un problème pour diriger tous les vaisseaux 50k à la fois)
PlayerComponent public struct PlayerData : IComponentData { } public class PlayerComponent : ComponentDataWrapper<PlayerData> { }
Et tout de suite, un lecteur d'entrée utilisateur.
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 }; } } }
Au lieu de l'entrée standard, il peut y avoir n'importe quel vélo ou IA préféré.
Et enfin, le traitement des contrôles et le virage lui-même. J'étais confronté au fait que math.euler n'était pas encore implémenté, donc un raid rapide sur Wikipedia m'a sauvé de la conversion des coins d'Euler au 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); } }
Vous vous demanderez probablement pourquoi vous ne pouvez pas simplement passer 3 composants à la fois à Job, comme dans MovementSystem? Parce que. J'ai lutté avec ça pendant longtemps, mais je ne sais pas pourquoi ça ne marche pas comme ça. Dans les exemples, les virages sont implémentés via ComponentDataArray, mais nous ne reculerons pas devant les canons.
Nous jetons le préfabriqué sur la scène, suspendons les composants, attachons la caméra, installons des papiers peints ennuyeux et c'est parti!

Conclusion
Les gars d'Unity Technologies ont évolué dans la bonne direction du multithreading. Le Job System lui-même est encore humide (la version alpha est après tout), mais il est tout à fait utilisable et accélère maintenant. Malheureusement, les composants standard ne sont pas compatibles avec le Job System (mais pas avec l'ECS séparément!), Vous devez donc sculpter des béquilles pour contourner ce problème. Par exemple, une personne du forum Unity implémente son système physique pour le GPU et, comme, fait des progrès.
ECS avec Unity a été utilisé auparavant, il existe plusieurs analogues prospères, par exemple,
un article avec un aperçu des plus célèbres. Il décrit également les avantages et les inconvénients de cette approche de l'architecture.
De moi-même, je peux ajouter un plus comme la pureté du code. J'ai commencé par essayer d'implémenter le mouvement dans un seul système. Le nombre de composants de dépendance a augmenté rapidement et j'ai dû diviser le code en petits systèmes pratiques. Et ils peuvent être facilement réutilisés dans un autre projet.
Le code du projet est ici:
GitHub