Com o Unity3D, com o lançamento da versão 2018, tornou-se possível usar o sistema ECS nativo (para Unity), com sabor de multi-threading na forma de um sistema de tarefas. Não há muitos materiais na Internet (alguns projetos da própria Unity Technologies e alguns vídeos de treinamento no YouTube). Tentei perceber a escala e a conveniência do ECS, criando um pequeno projeto sem cubos e botões. Antes disso, eu não tinha experiência em projetar ECS, por isso levou dois dias para estudar materiais e reorientar o pensamento com OOP, um dia foi admirado pela abordagem e outros dois ou dois dias para desenvolver um projeto, combater a Unity, retirar amostras de cabelo e fumaça . O artigo contém um pouco de teoria e um pequeno exemplo de projeto.
O significado do ECS é bastante simples - uma entidade (
Entidade ) com seus componentes (
Componente ), que são processados pelo sistema (
Sistema ).
Essence
A entidade não possui lógica e armazena apenas componentes (muito semelhante ao GameObject na abordagem antiga do CPC). No Unity ECS, a classe Entity existe para isso.
Componente
Os componentes armazenam apenas dados e, às vezes, não contêm nada e são um marcador simples para processamento pelo sistema. Mas eles não têm nenhuma lógica. Herdado de ComponentDataWrapper. Pode ser processado por outro encadeamento (mas há uma nuance).
O sistema
Os sistemas são responsáveis pelo processamento de componentes. Na entrada, eles recebem do Unity uma lista de componentes processados para os tipos fornecidos e, nos métodos sobrecarregados (análogos de Update, Start, OnDestroy), ocorre a mágica da mecânica do jogo. Herdado de ComponentSystem ou JobComponentSystem.
Sistema de tarefas
A mecânica dos sistemas que permite o processamento paralelo de componentes. No sistema OnUpdate, uma estrutura de tarefa é criada e adicionada ao processamento. Em um momento de tédio e recursos livres, o Unity processará e aplicará os resultados aos componentes.
Multithreading e Unity 2018
Todo o trabalho do Job System ocorre em outros segmentos, e os componentes padrão (Transformar, Corpo rígido, etc.) não podem ser alterados em nenhum segmento, exceto o principal. Portanto, no pacote padrão existem componentes de “substituição” compatíveis - Componente de posição, Componente de rotação, Componente de renderizador de instância de malha.
O mesmo se aplica a estruturas padrão como Vector3 ou Quaternion. Os componentes para paralelização usam apenas os tipos de dados mais simples (float3, float4, isso é tudo, os programadores gráficos terão prazer) adicionados ao namespace Unity.Mathematics, também há uma classe matemática para processá-los. Sem strings, sem tipos de referência, apenas hardcore.
"Mostre-me o código"
Então, hora de mudar alguma coisa!
Crie um componente que armazene o valor da velocidade e também seja um dos marcadores do sistema que move objetos. O atributo Serializable permite definir e rastrear o valor no inspetor.
Speedcompponent[Serializable] public struct SpeedData : IComponentData { public int Value; } public class SpeedComponent : ComponentDataWrapper<SpeedData> {}
Usando o atributo Injetar, o sistema obtém uma estrutura contendo componentes
apenas daquelas entidades nas quais os três componentes estão presentes. Portanto, se alguma entidade tiver componentes PositionComponent e SpeedComponent, mas não RotationComponent, essa entidade não será adicionada à estrutura que entra no sistema. Assim, é possível filtrar entidades pela presença de um componente.
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); } } }
Agora todos os objetos que contêm esses três componentes avançam a uma determinada velocidade.
Foi fácil. Embora tenha levado um dia para pensar em ECS.
Mas pare. Onde está o sistema de empregos aqui?
O fato é que nada está quebrado o suficiente para usar multithreading. Hora de quebrar!
Tirei das amostras o sistema que dá origem às pré-fabricadas. De interessante - aqui está um pedaço de código:
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) }); }
Então, vamos colocar 1000 objetos. Ainda é bom demais para instanciar malhas na GPU. 5000 - também aprox. Vou mostrar o que acontece com 50.000 objetos.
O Depurador de Entidades apareceu no Unity, mostrando quantos ms cada sistema leva. Os sistemas podem ser ativados / desativados no tempo de execução, para ver quais objetos eles processam, em geral, algo insubstituível.
Obter tal bola nave espacial A ferramenta grava a uma velocidade de 15 qps, portanto, todo o ponto está nos números da lista de sistemas. O nosso, o MovementSystem, tenta mover todos os 50.000 objetos em cada quadro e o faz em média em 60 ms. Então, agora o jogo está quebrado o suficiente para otimização.
Fixamos o JobSystem ao sistema de movimentação.
Sistema de Movimento Modificado 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); } }
Agora, o sistema herda de JobComponentSystem e, em cada quadro, cria um manipulador especial para o qual o Unity transfere os mesmos 3 componentes e deltaTime do sistema.
Lançar a nave espacial novamente 0,15 ms (0,4 no pico, sim) versus 50-70! 50 mil objetos! Eu digitei esses números na calculadora, em resposta, ele mostrou uma cara feliz.
Gerência
Você pode olhar infinitamente para uma bola voadora ou voar entre os navios.
Precisa de um sistema de táxi.
O componente Rotação já está na pré-fabricada, crie um componente para armazenar controles.
Controlcompponent [Serializable] public struct RotationControlData : IComponentData { public float roll; public float pitch; public float yaw; } public class ControlComponent : ComponentDataWrapper<RotationControlData>{}
Também precisaremos de um componente de jogador (embora não haja problema em dirigir todos os navios de 50 mil ao mesmo tempo)
PlayerComponent public struct PlayerData : IComponentData { } public class PlayerComponent : ComponentDataWrapper<PlayerData> { }
E imediatamente, um leitor de entrada do usuário.
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 }; } } }
Em vez da entrada padrão, pode haver qualquer bicicleta ou AI favorita.
E, finalmente, controles de processamento e a própria curva. Fui confrontado com o fato de o math.euler ainda não ter sido implementado, portanto, uma rápida invasão na Wikipedia me salvou da conversão dos cantos de Euler para o 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); } }
Você provavelmente perguntará por que não pode passar 3 componentes de uma vez para Job, como no MovementSystem? Porque Eu lutei com isso por um longo tempo, mas não sei por que não funciona assim. Nas amostras, as voltas são implementadas por meio do ComponentDataArray, mas não voltaremos a partir dos cânones.
Jogamos a casa pré-fabricada no palco, desligamos os componentes, amarram a câmera, colocamos papéis de parede chatos e pronto!

Conclusão
Os caras da Unity Technologies avançaram na direção certa do multithreading. O sistema de tarefas em si ainda está úmido (a versão alfa é afinal), mas é bastante utilizável e está acelerando agora. Infelizmente, os componentes padrão não são compatíveis com o sistema de tarefas (mas não com o ECS separadamente!). Portanto, você precisa esculpir muletas para contornar isso. Por exemplo, uma pessoa do fórum do Unity implementa seu sistema físico para a GPU e, assim, progride.
O ECS com Unity foi usado anteriormente, existem vários análogos prósperos, por exemplo,
um artigo com uma visão geral dos mais famosos. Também descreve os prós e contras dessa abordagem da arquitetura.
De mim mesmo, posso adicionar uma vantagem como a pureza do código. Comecei tentando implementar o movimento em um sistema. O número de componentes de dependência cresceu rapidamente, e eu tive que dividir o código em sistemas pequenos e convenientes. E eles podem ser facilmente reutilizados em outro projeto.
O código do projeto está aqui:
GitHub