In Unity3D with the release of version 2018, it became possible to use a native (for Unity) ECS system, flavored with multithreading in the form of a Job System. There are not a lot of materials on the Internet (a couple of projects from Unity Technologies themselves and a couple of training videos on YouTube). I tried to understand the scale and convenience of ECS by making a small project not from cubes and buttons. Before that, I had no experience in designing ECS, so it took two days to study materials and rebuild thinking with OOP, a day went away to admire the approach, and another one or two days to develop the project, fight Unity, pull out hair and smoke samples . The article contains a bit of theory and a small sample project.
The meaning of ECS is quite simple - the entity (
Entity ) with its components (
Component ), which the system deals with (
System ).
Essence
The entity has no logic and stores only components (very similar to the GameObject in the old CPC approach). In Unity ECS for this there is a class Entity.
Component
Components store only data, and sometimes do not contain anything at all and are a simple marker for processing by the system. But they have no logic either. Inherited from ComponentDataWrapper. It can be processed by another thread (but there is a nuance).
System
Systems are responsible for processing components. At the entrance, they receive from Unity a list of processed components based on the specified types, and in the overloaded methods (analogs of Update, Start, OnDestroy), the magic of game mechanics takes place. Inherited from ComponentSystem or JobComponentSystem.
Job system
System mechanics that allows parallelization of component processing. In the OnUpdate system, a Job structure is created and added to the processing. At the time of boredom and free resources, Unity will process and apply the results to the components.
Multithreading and Unity 2018
All Job System work happens in other streams, and standard components (Transform, Rigidbody, etc.) cannot be changed in any stream except the main one. Therefore, the standard delivery has compatible “replacement” components - Position Component, Rotation Component, Mesh Instance Renderer Component.
The same applies to standard structures like Vector3 or Quaternion. The components for parallelization use only the simplest data types (float3, float4, that's all, graphics programmers will be satisfied) added in the Unity.Mathematics namespace, and there is also a math class for processing them. No strings, no reference types, only hardcore.
“Show me the code”
So time to move something!
Create a component that stores the value of speed, as well as being one of the markers for the system that moves objects. The Serializable attribute allows you to set and track the value in the inspector.
Speedcomponent[Serializable] public struct SpeedData : IComponentData { public int Value; } public class SpeedComponent : ComponentDataWrapper<SpeedData> {}
The system, using the Inject attribute, obtains a structure containing components of
only those entities that have all three components. So, if an entity has PositionComponent and SpeedComponent components, but not RotationComponent, this entity will not be added to the structure that enters the system. Thus, it is possible to filter entities by the presence of a component.
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); } } }
_shipsMovementData.Positions [i] .Value + math.forward (_shipsMovementData.Rotations [i] .Value) * Time.deltaTime * _shipsMovementData.Speeds [i] .Value); 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); } } }
Now all objects containing these three components will move forward at a given speed.
It was easy. Though it took one day to think about ECS.
But stop. Where is the job system?
The fact is that nothing is broken yet enough to use multi-threading. Time to break!
I pulled a system out of the samples, giving birth to prefabs. From interesting - here is such a piece of 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) }); }
So, put 1000 objects. Still too good due to instantiation of meshes on the GPU. 5000 is also ok. Show what happens when there are 50,000 objects.
Entity Debugger appeared in Unity, which shows how many ms each system operation takes. Systems can be turned on / off right in runtime, look at what objects they process, in general, an indispensable thing.
It will be such a space ball The tool records at a speed of 15 fps, so the whole point is in the numbers in the list of systems. Our MovementSystem tries to move all 50,000 objects in each frame, and it does this on average in 60 ms. So now the game is broken enough for optimization.
Let's attach the JobSystem to the movement system.
Modified MovementSystem 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); } }
Now the system is inherited from JobComponentSystem and in each frame creates a special handler to which Unity transfers the same 3 components and deltaTime from the system.
0.15 ms (0.4 at the peak, yes) versus 50-70! 50 thousand objects! I entered these numbers in a calculator, in response, he showed a happy face.
Control
You can endlessly look at the flying ball, and you can fly among the ships.
Need a taxi system.
The Rotation component is already on the prefab; let's create a component for storing the controls.
Controlcomponent [Serializable] public struct RotationControlData : IComponentData { public float roll; public float pitch; public float yaw; } public class ControlComponent : ComponentDataWrapper<RotationControlData>{}
We also need a player component (although it’s not a problem to steer all 50k ships at once)
Playercomponent public struct PlayerData : IComponentData { } public class PlayerComponent : ComponentDataWrapper<PlayerData> { }
And immediately - the user input reader system.
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 }; } } }
Instead of the standard Input, there can be any favorite samopisny bicycle or AI.
And, finally, the handling of controls and the rotation itself. I was faced with the fact that math.euler has not yet been implemented, so a quick raid on Wikipedia saved me from recounting from Euler angles to 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); } }
Probably, you ask, why it is impossible to simply transfer 3 components at once to a Job, as in the MovementSystem? Because. I struggled with this for a long time, but I do not know why it does not work that way. In samples, the turns are implemented through ComponentDataArray, but let's not retreat from the canons.
We throw out the prefab on the stage, hang up the components, tie the camera, set the non-dull wallpaper, and go!

Conclusion
The guys from Unity Technologies moved in the right direction of multi-threading. The Job System itself is still damp (alpha version, after all), but it is quite usable and gives acceleration now. Unfortunately, the standard components are incompatible with the Job System (but not with ECS separately!), So you have to sculpt crutches to get around this. For example, one person from the Unity forum implements its physical system for the GPU, and it seems to be making progress.
ECS with Unity has been used before, there are several flourishing analogues, for example,
an article with an overview of the most famous. It also describes the pros and cons of this approach to architecture.
From myself I can add such a plus as the purity of the code. I began by trying to realize movement in one system. The number of component dependencies grew rapidly, and I had to divide the code into small and convenient systems. And they can be easily reused in another project.
The project code is here:
GitHub