Física para um shooter PvP móvel e como fizemos amizade com a ECS

Olá pessoal! Neste artigo, falaremos sobre a experiência pessoal de trabalhar com motores físicos para um jogo de tiro multiplayer e focar principalmente na interação da física e da ECS : que tipo de rake adotamos durante o trabalho, o que aprendemos e por que decidimos soluções específicas.



Primeiro, vamos descobrir por que um mecanismo físico é necessário. Não há resposta universal: em todo jogo, ele serve a seu propósito. Alguns jogos usam mecanismos físicos para simular corretamente o comportamento dos objetos no mundo, a fim de obter o efeito de imergir o jogador. Em outros, a física é a base da jogabilidade - como, por exemplo, Angry Birds e Red Faction. Também existem “caixas de areia” nas quais as leis físicas diferem das usuais e, portanto, tornam a jogabilidade mais interessante e incomum (Portal, A Slow Speed ​​of Light).

Do ponto de vista da programação, o mecanismo físico torna possível simplificar o processo de simulação do comportamento dos objetos em um jogo. De fato, é uma biblioteca que armazena descrições das propriedades físicas dos objetos. Na presença de um mecanismo físico, não precisamos desenvolver o sistema de interação de corpos e leis universais pelas quais o mundo do jogo viverá. Isso economiza muito tempo e esforço de desenvolvimento.

imagem
O diagrama acima descreve a essência do Player, seus componentes e seus dados, e os sistemas que funcionam com o Player e seus componentes. O principal objeto do diagrama é o jogador: ele pode se mover no espaço - componentes Transform e Movement, MoveSystem; tem alguma saúde e pode morrer - componente Health, Damage, DamageSystem; após a morte aparecer no ponto de respawn - o componente Transform da posição, o RespawnSystem; pode ser invulnerável - componente invencível.

Quais são os recursos da implementação da física dos jogos para atiradores?


Não há interações físicas complexas em nosso jogo, mas há várias coisas para as quais um mecanismo físico ainda é necessário. Inicialmente, planejamos usá-lo para mover um personagem no mundo de acordo com leis pré-determinadas. Geralmente, isso é feito dando ao corpo um certo impulso ou velocidade constante, após o que, usando o método Simular / Atualizar da biblioteca, todos os corpos registrados nele são simulados exatamente um passo à frente.

Nos atiradores, a física 3D é frequentemente usada não apenas para simular movimentos dos personagens, mas também para o processamento correto da balística de balas e foguetes, saltos, a interação dos personagens entre si e com o ambiente. Se o atirador afirma ser realista e procura transmitir as reais sensações do processo de tiro, ele precisa apenas de um mecanismo físico. Quando um jogador dispara uma espingarda contra um alvo, ele espera obter experiência e um resultado o mais próximo possível do que ele já conhece em um jogo de tiro de longa duração - algo radicalmente novo provavelmente o surpreenderá desagradável.

Mas no caso do nosso jogo, existem várias limitações. Como nosso atirador é móvel, não implica interações complexas de personagens entre si e com o mundo circundante, não requer balística bonita, destrutibilidade, pulando em uma superfície irregular. Mas, ao mesmo tempo e pelo mesmo motivo, existem requisitos de tráfego muito rigorosos. A física 3D, nesse caso, seria redundante: usaria apenas uma pequena parte de seus recursos de computação e geraria dados desnecessários, os quais em uma rede móvel e a sincronização constante do cliente com o servidor via UDP ocupariam muito espaço. Aqui, vale lembrar que, em nosso modelo de rede, ainda existem coisas como Previsão e Reconciliação , que também envolvem acordos no cliente. Como resultado, concluímos que nossa física deve funcionar o mais rápido possível para iniciar e trabalhar com êxito em dispositivos móveis, sem interferir na renderização e em outros subsistemas clientes.

Portanto, a física 3D não nos convinha. Mas aqui vale lembrar que, mesmo que o jogo pareça tridimensional, não é fato que a física nele também seja tridimensional: tudo determina a natureza da interação dos objetos entre si. Muitas vezes, os efeitos que não podem ser abordados pela física 2D são personalizados - ou seja, é escrita uma lógica que se parece com interações tridimensionais - ou simplesmente substituídos por efeitos visuais que não afetam a jogabilidade. Em Heroes of the Storm, Defense of the Ancients, League of Legends, a física bidimensional é capaz de fornecer todos os recursos de jogabilidade do jogo sem afetar a qualidade da imagem ou o sentimento de credibilidade criado por designers e artistas do mundo todo. Então, por exemplo, nesses jogos, há personagens pulando, mas não há sentido físico na altura do salto, então se resume a uma simulação bidimensional e definindo algum tipo de bandeira como _isInTheAir quando o personagem está no ar - é levado em consideração ao calcular a lógica.

Por isso, foi decidido usar a física 2D. Nós escrevemos o jogo no Unity, mas o servidor usa .net sem Unity, o que o Unity não entende. Como a maior parte do código de simulação é vasculhada entre o cliente e o servidor, começamos a procurar algo entre plataformas - a saber, uma biblioteca física escrita em C # puro sem usar código nativo para eliminar o perigo de travar plataformas móveis. Além disso, levando em consideração as especificidades do trabalho dos atiradores , em particular as constantes rebobinamentos no servidor para determinar onde o jogador atirou, era importante para nós que a biblioteca pudesse trabalhar com a história - ou seja, poderíamos ver a posição dos corpos N quadros de maneira barata no tempo . E, é claro, o projeto não deve ser abandonado: é importante que o autor o apoie e possa rapidamente corrigir os erros, se houver algum durante a operação.

Como se viu, naquela época muito poucas bibliotecas podiam satisfazer nossos requisitos. De fato, apenas um foi adequado para nós - VolatilePhysics .

A biblioteca é digna de nota por funcionar com as soluções Unity e sem unidade, e também permite que você faça rakecasts no estado passado de objetos fora da caixa, ou seja, Adequado para a lógica do atirador. Além disso, a conveniência da biblioteca reside no fato de que o mecanismo para controlar o início da simulação Simulate () permite que ela seja executada a qualquer momento quando o cliente precisar. E outro recurso - a capacidade de gravar dados adicionais no corpo físico. Isso pode ser útil ao endereçar um objeto a partir de uma simulação nos resultados do reykast - no entanto, isso reduz bastante o desempenho.

Depois de fazer alguns testes e garantir que o cliente e o servidor interajam bem com o VolatilePhysics sem falhas, optamos por isso.

Como entramos na biblioteca da maneira usual de trabalhar com o ECS e o que aconteceu


O primeiro passo ao trabalhar com VolatilePhysics é criar o mundo físico do VoltWorld. É uma classe proxy, com a qual o trabalho principal ocorre: ajustando, simulando dados sobre objetos, reykast etc. Nós o envolvemos em uma fachada especial para que no futuro possamos mudar a implementação da biblioteca para outra coisa. O código da fachada ficou assim:

Ver código
public sealed class PhysicsWorld { public const int HistoryLength = 32; private readonly VoltWorld _voltWorld; private readonly Dictionary<uint, VoltBody> _cache = new Dictionary<uint, VoltBody>(); public PhysicsWorld(float deltaTime) { _voltWorld = new VoltWorld(HistoryLength) { DeltaTime = deltaTime }; } public bool HasBody(uint tag) { return _cache.ContainsKey(tag); } public VoltBody GetBody(uint tag) { VoltBody body; _cache.TryGetValue(tag, out body); return body; } public VoltRayResult RayCast(Vector2 origin, Vector2 direction, float distance, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.RayCast(ref ray, ref result, filter, ticksBehind); return result; } public VoltRayResult CircleCast(Vector2 origin, Vector2 direction, float distance, float radius, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.CircleCast(ref ray, radius, ref result, filter, ticksBehind); return result; } public void Update() { _voltWorld.Update(); } public void Update(uint tag) { var body = _cache[tag]; _voltWorld.Update(body, true); } public void UpdateBody(uint tag, Vector2 position, float angle) { var body = _cache[tag]; body.Set(position, angle); } public void CreateStaticCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateStaticBody(origin, 0, shape); body.UserData = tag; } public void CreateDynamicCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateDynamicBody(origin, 0, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public void CreateStaticSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateStaticBody(origin, rotationAngle, shape); body.UserData = tag; } public void CreateDynamicSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateDynamicBody(origin, rotationAngle, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public IEnumerable<VoltBody> GetBodies() { return _voltWorld.Bodies; } private static bool StaticCollisionFilter(VoltBody a, VoltBody b) { return b.IsStatic; } } 


Ao criar o mundo, a magnitude da história é indicada - o número de estados do mundo que a biblioteca armazenará. No nosso caso, o número deles era de 32: 30 quadros por segundo, precisaremos dele com base no requisito de atualizar a lógica e mais 2 no caso de ultrapassarmos os limites do processo de depuração. O código também leva em consideração os métodos de conversão externa que geram corpos físicos e vários tipos de reykast.

Como lembramos de artigos anteriores , o mundo do ECS gira essencialmente em torno de uma chamada regular para métodos Execute para todos os sistemas incluídos nele. Nos lugares certos de cada sistema, usamos chamadas para nossa fachada. Inicialmente, não escrevemos nenhum lote para desafiar o mecanismo físico, embora existissem esses pensamentos. Dentro da fachada, ocorre uma chamada para Update () do mundo físico, e a biblioteca simula todas as interações de objetos que ocorreram por quadro.

Assim, o trabalho com a física se resume a dois componentes: o movimento uniforme dos corpos no espaço em um quadro e os muitos rakast necessários para fotografar, a operação adequada dos efeitos e muitas outras coisas. Reykasts são especialmente relevantes na história do estado dos corpos físicos.

De acordo com os resultados de nossos testes, rapidamente percebemos que a biblioteca funciona muito mal em velocidades diferentes e, a uma certa velocidade, os corpos começam a passar facilmente pelas paredes. Não há configurações associadas à detecção contínua de colisões para resolver esse problema em nosso mecanismo. Mas não havia alternativas para nossa solução no mercado naquele momento, então tive que criar minha própria versão de objetos em movimento ao redor do mundo e sincronizar dados físicos com o ECS. Assim, por exemplo, nosso código para o sistema de movimentação é o seguinte:

Ver código
 using System; ... using Volatile; public sealed class MovePhysicsSystem : ExecutableSystem { private readonly PhysicsWorld _physicsWorld; private readonly CollisionFilter _moveFilter; private readonly VoltBodyFilter _collisionFilterDelegate; public MovePhysicsSystem(PhysicsWorld physicsWorld) { _physicsWorld = physicsWorld; _moveFilter = new CollisionFilter(true, CollisionLayer.ExplosiveBarrel); _collisionFilterDelegate = _moveFilter.Filter; } public override void Execute(GameState gs) { _moveFilter.State = gs; foreach (var pair in gs.WorldState.Movement) { ExecuteMovement(gs, pair.Key, pair.Value); } _physicsWorld.Update(); foreach (var pair in gs.WorldState.PhysicsDynamicBody) { if(pair.Value.IsAlive) { ExecutePhysicsDynamicBody(gs, pair.Key); } } } public override void Execute(GameState gs, uint avatarId) { _moveFilter.State = gs; var movement = gs.WorldState.Movement[avatarId]; if (movement != null) { ExecuteMovement(gs, avatarId, movement); _physicsWorld.Update(avatarId); var physicsDynamicBody = gs.WorldState.PhysicsDynamicBody[avatarId]; if (physicsDynamicBody != null && physicsDynamicBody.IsAlive) ExecutePhysicsDynamicBody(gs, avatarId); } } private void ExecutePhysicsDynamicBody(GameState gs, uint entityId) { var body = _physicsWorld.GetBody(entityId); if (body != null) { var transform = gs.WorldState.Transform[entityId]; transform.Position = body.Position; } } private void ExecuteMovement(GameState gs, uint entityId, Movement movement) { var body = _physicsWorld.GetBody(entityId); if (body != null) { float raycastRadius; if (CalculateRadius(gs, entityId, out raycastRadius)) { return; } body.AngularVelocity = 0; body.LinearVelocity = movement.Velocity; var movPhysicInfo = gs.WorldState.MovementPhysicInfo[entityId]; var collisionDirection = CircleRayCastSpeedCorrection(body, GameState.TickDurationSec, raycastRadius); CheckMoveInWall(movement, movPhysicInfo, collisionDirection, gs.WorldState.Transform[entityId]); } } private static bool CalculateRadius(GameState gs, uint id, out float raycastRadius) { raycastRadius = 0; var circleShape = gs.WorldState.DynamicCircleCollider[id]; if (circleShape != null) { raycastRadius = circleShape.Radius; } else { var boxShape = gs.WorldState.DynamicBoxCollider[id]; if (boxShape != null) { raycastRadius = boxShape.RaycastRadius; } else { gs.Log.Error(string.Format("Physics body {0} doesn't contains shape!", id)); return true; } } return false; } private static void CheckMoveInWall(Movement movement, MovementPhysicInfo movPhysicInfo, Vector2 collisionDirection, Transform transform) { // 60 is the max angle when player move in wall and can shoot through the wall from weapon without target. const float maxAngleToWall = 60; if (movement.Velocity.IsEqual(Vector2.zero)) { if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } return; } movPhysicInfo.LastCollisionDirection = collisionDirection * -1f; if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { movPhysicInfo.TurnOnWall = false; movPhysicInfo.LastCollisionDirection = collisionDirection; } else { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } } // I can't believe we are using a physics engine and have to write such kludges private Vector2 CircleRayCastSpeedCorrection(VoltBody targetBody, float deltaSeconds, float rayCastRadius) { if (rayCastRadius <= 0) { return Vector2.zero; } var speed = targetBody.LinearVelocity; var position = targetBody.Position; var direction = speed * deltaSeconds; var rayCastResult = _physicsWorld.CircleCast(position + direction.normalized * 0.1f, direction, direction.magnitude, rayCastRadius, _collisionFilterDelegate, 0); if (rayCastResult.Body == null) { return Vector2.zero; } var magSpeed = speed.magnitude; if (rayCastResult.Distance > 0) { var penetratingDistance = magSpeed * deltaSeconds - rayCastResult.Distance; var sinVelocityEdge = Vector2.Dot(-speed.normalized, rayCastResult.Normal); var biasSpeed = penetratingDistance * sinVelocityEdge / deltaSeconds; var biasVector = rayCastResult.Normal * biasSpeed * 1.1f; var resultVelocity = speed + biasVector; if (magSpeed <= 0) { resultVelocity = Vector2.zero; } targetBody.LinearVelocity = resultVelocity; return rayCastResult.Normal; } var destination = rayCastResult.Body.Position; direction = destination - position; var rayCastResultToBody = _physicsWorld.RayCast(position, direction, direction.magnitude, _collisionFilterDelegate, 0); if (rayCastResultToBody.IsValid) targetBody.LinearVelocity = rayCastResultToBody.Normal * magSpeed * deltaSeconds; return rayCastResultToBody.Normal; } } 


A idéia é que, antes de cada personagem se mover, fazemos um CircleCast na direção de seu movimento, a fim de determinar se há um obstáculo à sua frente. O CircleCast é necessário porque as projeções dos personagens do jogo representam um círculo, e não queremos que eles fiquem presos nos cantos entre diferentes geometrias. Então consideramos o incremento de velocidade e atribuímos esse valor ao objeto do mundo físico como sua velocidade em um quadro. O próximo passo é chamar o método de simulação do mecanismo físico Update (), que move todos os objetos de que precisamos, registrando simultaneamente o estado antigo no histórico. Após a conclusão da simulação no mecanismo, lemos esses dados simulados, copiamos para o componente Transform do nosso ECS e continuamos a trabalhar com ele, em particular, enviando-o pela rede.

Essa abordagem na atualização da física com pequenos blocos de dados controlados sobre a velocidade de movimento do personagem se mostrou muito eficaz para lidar com discrepâncias na física no cliente e no servidor. E como nossa física não é determinística - ou seja, com os mesmos dados de entrada, o resultado da simulação pode variar - houve muitas discussões sobre se vale a pena usá-lo e se alguém da indústria faz algo semelhante, ter um mecanismo físico determinístico na mão. Felizmente, encontramos um excelente relatório dos desenvolvedores do NetherRealm Studios na Game Developers Conference sobre o componente de rede de seus jogos e percebemos que essa abordagem realmente ocorre. Depois de montar o sistema completamente e executá-lo em vários testes, obtivemos cerca de 50 previsões falsas para 9000 ticks, ou seja, durante a batalha de cinco minutos. Esse número de previsões perdidas é facilmente nivelado pelo mecanismo de reconciliação e interpolação visual da posição do jogador. Os erros que ocorrem durante atualizações manuais freqüentes da física usando seus próprios dados são insignificantes; portanto, a interpolação visual pode ocorrer rapidamente - é necessária apenas para que não ocorra um salto visual no modelo de personagem.

Para verificar a coincidência dos estados do cliente e do servidor, usamos uma classe auto-escrita do seguinte formato:

Ver código
 using PS.Logs.Unity; /// <summary> /// Compares the same avatar in two states. Compares the values potentially /// affected by prediction. /// </summary> public sealed class GameStateComparer : IGameStateComparer { public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) { return false; } if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) { return false; } if (entity1 == null || entity2 == null) { LogManager.Debug("entity is different"); return false; } if (s1.Time != s2.Time) { LogManager.Warning(string.Format("Trying to compare states with different time! Predicted time: {0} Server time: {1}", s1.Time, s2.Time)); return false; } if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) { LogManager.Debug("Transform is different"); return false; } // ... some code ... return true; } } 


Se necessário, pode ser automatizado, mas não fizemos isso, embora tenhamos pensado nisso no futuro.

Transforme o código de comparação:

Ver código
 public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) { return true; } if ((object)a == null && (object)b != null) { return false; } if ((object)a != null && (object)b == null) { return false; } if (Math.Abs(a.Angle - b.Angle) > 0.01f) { return false; } if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) { return false; } return true; } 



Primeiras dificuldades


Não havia problemas com a simulação do movimento, embora ele pudesse ser projetado em um plano 2D - a física nesses casos funcionava muito bem, mas a certa altura os designers de jogos chegaram e disseram: “Queremos granadas!” E pensamos que nada mudaria muito , por que não simular o vôo 3D do corpo físico com apenas dados 2D em mãos.

E eles introduziram o conceito de altura para alguns objetos.

Como a lei da altitude muda ao longo do tempo para um corpo abandonado, eles passam por aulas de física na oitava série, de modo que a decisão sobre balística acabou sendo trivial. Mas a solução com colisões não era mais tão trivial. Vamos imaginar este caso: uma granada durante o vôo deve colidir com uma parede ou sobrevoá-la, dependendo da altura atual e da altura da parede. Só resolveremos o problema no mundo bidimensional, onde a granada é representada por um círculo e a parede por um retângulo.


Vista da geometria dos objetos para resolver o problema.

Antes de tudo, desligamos a interação do corpo dinâmico de uma granada com outros corpos estáticos e dinâmicos. Isso é necessário para se concentrar no objetivo. Em nossa tarefa, uma granada deve ser capaz de passar por outros objetos e "sobrevoar" uma parede quando suas projeções em um plano bidimensional se cruzam. Em uma interação normal, dois objetos não podem passar um pelo outro e, no entanto, no caso de uma granada com uma lógica e altura de movimento personalizadas, permitimos que isso fosse feito sob certas condições.

Introduzimos um componente GrenadeMovement separado para a granada, no qual introduzimos o conceito de altura:

 [Component] public class GrenadeMovement { public float Height; [DontPack] public Vector2 Velocity; [DontPack] public float VerticalVelocity; public GrenadeMovement(float height, Vector2 velocity, float verticalVelocity) { } } 

Agora a granada tem uma coordenada de altura, mas essa informação não dá nada ao resto do mundo. Portanto, decidimos trapacear e adicionamos a seguinte condição: uma granada pode voar sobre muros, mas apenas de uma certa altura. Assim, toda a definição de colisão se resumiu a verificar colisões de projeção e comparar a altura da parede com o valor do campo GrenadeMovement.Height. Se a altura do vôo da granada for menor, ela colide com a parede; caso contrário, ela pode continuar calmamente se movendo ao longo do caminho, inclusive no espaço 2D.

Na primeira iteração, a granada simplesmente caiu ao encontrar cruzamentos, mas adicionamos colisões elásticas, e começou a se comportar quase indistinguível do resultado que obteríamos em 3D.

O código completo para calcular a trajetória de uma granada e colisões elásticas é dado abaixo:

Ver código
 using System; // ... some code ... using Volatile; namespace Common.WorldState { public sealed class GrenadeMovementSystem : ExecutableSystem { private struct Projection { public float Min; public float Max; } private float _r; private readonly Vector2[] _vertices = new Vector2[4]; private readonly Vector2[] _verticesV = new Vector2[4]; private Vector2 _Vunit; private Vector2 _VTunit; private Projection _wallProj1; private Projection _wallProj2; private Projection _wallProj1V; private Projection _wallProj2V; private const float CollisionPrecision = 1e-3f; private static readonly float HalfSlope = Mathf.Cos(Mathf.PI / 4.0f); private readonly ContactPointList _contactPoints = new ContactPointList(3); public override void Execute(GameState gs) { var settings = gs.RuleBook.GrenadeConfig[1]; _r = settings.R; var floorDampeningPerTick = (float)Math.Pow(settings.FloorDampening, 1.0 / GameState.Hz); foreach (var grenade in gs.WorldState.GrenadeMovement) { // Gravity must take effect before collision // because contact with walls may and will adjust vertical velocity // and penetration will even move the ball up. grenade.Value.VerticalVelocity -= settings.Gravity * GameState.TickDurationSec; grenade.Value.Height += grenade.Value.VerticalVelocity * GameState.TickDurationSec; // prevent falling through floor if (grenade.Value.Height <= _r) { // slow down horizontal movement by floor friction // actually, friciton is simplified to just dampening coefficient var spdH = grenade.Value.Velocity.sqrMagnitude; var spdV = grenade.Value.VerticalVelocity; var cos = spdH / Mathf.Sqrt(spdH * spdH + spdV * spdV); grenade.Value.Velocity *= floorDampeningPerTick * cos; // slow down vertical movement grenade.Value.VerticalVelocity = settings.FloorRestitution * Math.Abs(grenade.Value.VerticalVelocity); // move up to the floor level grenade.Value.Height = _r; } // A collision will stop the ball and change its velocity. // Otherwise it will be moved by velocity PerformCollisionAndMovement(gs, grenade.Key, grenade.Value); } } private void PerformCollisionAndMovement(GameState gs, uint id, GrenadeMovement grenade) { var settings = gs.RuleBook.GrenadeConfig[1]; var velocity = grenade.Velocity * GameState.TickDurationSec; var trans = gs.WorldState.Transform[id]; var position = trans.Position; _Vunit = velocity.normalized; _VTunit = new Vector2(-_Vunit.y, _Vunit.x); _vertices[0] = position + _VTunit * _r; _vertices[1] = position - _VTunit * _r; _vertices[2] = _vertices[1] + velocity; _vertices[3] = _vertices[0] + velocity; _contactPoints.Reset(); int collisions = 0; var grenProj1V = ProjectCapsule(_Vunit, _vertices, position, velocity); var grenProj2V = ProjectCapsule(_VTunit, _vertices, position, velocity); collisions += CollideWithStaticBoxes(gs, id, position, velocity, grenade, grenProj1V, grenProj2V); collisions += CollideWithCircles(gs, gs.RuleBook.StaticCircleCollider, gs.RuleBook.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, (CollisionLayer)~0); collisions += CollideWithCircles(gs, gs.WorldState.DynamicCircleCollider, gs.WorldState.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, ~CollisionLayer.Character); if (collisions == 0) { trans.Position += velocity; } else { var contactSuperposition = CalculateContactSuperposition(); trans.Position += velocity * contactSuperposition.TravelDistance; var reflectedVelocity = grenade.Velocity - 2.0f * Vector2.Dot(grenade.Velocity, contactSuperposition.Normal) * contactSuperposition.Normal; reflectedVelocity *= settings.WallRestitution; #if DEBUG_GRENADES gs.Log.Debug("contact" + "\n\ttravel " + contactSuperposition.TravelDistance + "\n\tcontactNormal " + contactSuperposition.Normal.x + ":" + contactSuperposition.Normal.y + "\n\treflected V " + reflectedVelocity.x + ":" + reflectedVelocity.y); #endif grenade.Velocity = reflectedVelocity; } } private int CollideWithStaticBoxes( GameState gs, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; // TODO spatial query foreach (var collider in gs.RuleBook.StaticBoxCollider) { var wall = collider.Value; var transform = gs.RuleBook.Transform[collider.Key]; var colliderData = gs.RuleBook.PrecomputedColliderData[collider.Key]; // test projection to V _wallProj1V = ProjectPolygon(_Vunit, colliderData.Vertices); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectPolygon(_VTunit, colliderData.Vertices); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test projection to wall axis 1 _wallProj1 = ProjectPolygon(colliderData.Axis1, colliderData.Vertices); var grenProj1 = ProjectCapsule(colliderData.Axis1, _vertices, position, velocity); if (!Overlap(_wallProj1, grenProj1)) continue; // test projection to wall axis 2 _wallProj2 = ProjectPolygon(colliderData.Axis2, colliderData.Vertices); var grenProj2 = ProjectCapsule(colliderData.Axis2, _vertices, position, velocity); if (!Overlap(_wallProj2, grenProj2)) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { var localPV = WorldToBoxLocal(transform.Position, colliderData, position + velocity); #if DEBUG_GRENADES gs.Log.Debug("fall on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tlocal " + localPV.x + ":" + localPV.y + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (Math.Abs(localPV.x) < wall.Size.x * 0.5f || Math.Abs(localPV.y) < wall.Size.y * 0.5f) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckBoxCollision, colliderData, 0, position, velocity); var contactPoint = CalcBoxContactPoint(transform.Position, wall, colliderData, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with static box #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\twall pos " + transform.Position.x + ":" + transform.Position.y + " sz " + wall.Size.x + ":" + wall.Size.y + " angle " + transform.Angle + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\taxis1 " + colliderData.Axis1.x + ":" + colliderData.Axis1.y + "\n\tproj 1 w " + _wallProj1.Min + ":" + _wallProj1.Max + " g " + grenProj1.Min + ":" + grenProj1.Max + " overlap=" + Overlap(_wallProj1, grenProj1) + "\n\taxis2 " + colliderData.Axis2.x + ":" + colliderData.Axis2.y + "\n\tproj 2 w " + _wallProj2.Min + ":" + _wallProj2.Max + " g " + grenProj2.Min + ":" + grenProj2.Max + " overlap=" + Overlap(_wallProj2, grenProj2) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckBoxCollision(PrecomputedColliderData colliderData, int x, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test projection to wall axis 1 var grenProj1 = ProjectCapsule(colliderData.Axis1, _verticesV, position, velocity); if (!Overlap(_wallProj1, grenProj1)) return false; // test projection to wall axis 2 var grenProj2 = ProjectCapsule(colliderData.Axis2, _verticesV, position, velocity); if (!Overlap(_wallProj2, grenProj2)) return false; return true; } private int CollideWithCircles( GameState gs, Table<CircleCollider> colliderTable, Table<Transform> transformTable, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V, CollisionLayer collisionLayers) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; foreach (var collider in colliderTable) { if ((int)collisionLayers != ~0) { var body = gs.WorldState.PhysicsDynamicBody[collider.Key]; if (body != null && (body.CollisionLayer & collisionLayers) == 0) continue; } var wall = collider.Value; var transform = transformTable[collider.Key]; // test projection to V _wallProj1V = ProjectCircle(_Vunit, transform.Position, wall.Radius); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectCircle(_VTunit, transform.Position, wall.Radius); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test distance from the circle wall to semicircles on capsule ends var collisionDistance = (_r + wall.Radius) * (_r + wall.Radius); if ((position - transform.Position).sqrMagnitude > collisionDistance) continue; var distSqr = (position + velocity - transform.Position).sqrMagnitude; if (distSqr > collisionDistance) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { #if DEBUG_GRENADES gs.Log.Debug("grenade #" + id + " falls on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tdist " + Mathf.Sqrt(distSqr) + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (distSqr < wall.Radius * wall.Radius) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckCircleCollision, transform.Position, wall, position, velocity); var contactPoint = CalcCircleContactPoint(transform.Position, wall, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with circle #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\tcircle pos " + transform.Position.x + ":" + transform.Position.y + " r " + wall.Radius + "\n\tdist " + (transform.Position - (P + V * scaleV)).magnitude + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckCircleCollision(Vector2 wallCentre, CircleCollider wall, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test distance from the circle wall to the semicircle on the second capsule end var dSqr = (_r + wall.Radius) * (_r + wall.Radius); return (position + velocity - wallCentre).sqrMagnitude < dSqr; } private static float CalcTranslationScaleBeforeCollision<TData1, TData2>( Func<TData1, TData2, Vector2, Vector2, bool> collision, TData1 colliderData1, TData2 colliderData2, Vector2 position, Vector2 vector) { var min = 0.0f; var max = 1.0f; while (true) { var d = (max - min) * 0.5f; if (d < CollisionPrecision) break; var scale = min + d; if (collision(colliderData1, colliderData2, position, vector * scale)) { max = scale; } else { min = scale; } } return min; } private ContactPoint CalculateContactSuperposition() { ContactPoint contactSuperposition; _contactPoints.TryPopClosest(1000f, out contactSuperposition); ContactPoint contact; while (_contactPoints.TryPopClosest(contactSuperposition.TravelDistance, out contact)) { contactSuperposition.Normal += contact.Normal; } contactSuperposition.Normal = contactSuperposition.Normal.normalized; return contactSuperposition; } private static Projection ProjectPolygon(Vector2 axisNormalised, Vector2[] vertices) { Projection proj; var d = Vector2.Dot(axisNormalised, vertices[0]); proj.Min = d; proj.Max = d; for (var i = 1; i < vertices.Length; i++) { d = Vector2.Dot(axisNormalised, vertices[i]); proj.Min = Mathf.Min(proj.Min, d); proj.Max = Mathf.Max(proj.Max, d); } return proj; } private Projection ProjectCapsule(Vector2 axisNormalised, Vector2[] vertices, Vector2 p, Vector2 v) { var proj = ProjectPolygon(axisNormalised, vertices); proj = AddCircleProjection(proj, axisNormalised, p, _r); proj = AddCircleProjection(proj, axisNormalised, p + v, _r); return proj; } private static Projection AddCircleProjection(Projection proj, Vector2 axisNormalised, Vector2 centre, float r) { var c = Vector2.Dot(axisNormalised, centre); proj.Min = Mathf.Min(proj.Min, c - r); proj.Max = Mathf.Max(proj.Max, c + r); return proj; } private static Projection ProjectCircle(Vector2 axisNormalised, Vector2 centre, float r) { Projection proj; var c = Vector2.Dot(axisNormalised, centre); proj.Min = c - r; proj.Max = c + r; return proj; } private static bool Overlap(Projection p1, Projection p2) { return p1.Min < p2.Min ? p1.Max > p2.Min : p2.Max > p1.Min; } private static Vector2 WorldToBoxLocal(Vector2 wallCentre, PrecomputedColliderData colliderData, Vector2 position) { return new Vector2( Vector2.Dot(colliderData.Axis1, position) - Vector2.Dot(colliderData.Axis1, wallCentre), Vector2.Dot(colliderData.Axis2, position) - Vector2.Dot(colliderData.Axis2, wallCentre) ); } private static ContactPoint CalcBoxContactPoint(Vector2 wallCentre, BoxCollider wall, PrecomputedColliderData colliderData, Vector2 position) { var contactPoint = CaclBoxLocalContactPoint(wall.Size * 0.5f, WorldToBoxLocal(wallCentre, colliderData, position)); var worldAxisX = new Vector2(colliderData.Axis1.x, -colliderData.Axis1.y); var worldAxisY = new Vector2(colliderData.Axis1.y, colliderData.Axis1.x); contactPoint.Point = wallCentre + new Vector2(Vector2.Dot(worldAxisX, contactPoint.Point), Vector2.Dot(worldAxisY, contactPoint.Point)); contactPoint.Normal = new Vector2(Vector2.Dot(worldAxisX, contactPoint.Normal), Vector2.Dot(worldAxisY, contactPoint.Normal)); return contactPoint; } private static ContactPoint CaclBoxLocalContactPoint(Vector2 boxHalfSize, Vector2 localPosition) { ContactPoint localContactPoint = default(ContactPoint); // cases are numbered like numpad keys // 1, 2, 3 if (localPosition.y < -boxHalfSize.y) { // 1 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, -HalfSlope); } // 2, 3 else { // 3 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, -HalfSlope); } // 2 else { localContactPoint.Point = new Vector2(localPosition.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, -1.0f); } } } // 4, 6, 7, 8, 9 else { // 7, 8, 9 if (localPosition.y > boxHalfSize.y) { // 7 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, HalfSlope); } // 8, 9 else { // 9 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, HalfSlope); } // 8 else { localContactPoint.Point = new Vector2(localPosition.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, 1.0f); } } } // 4, 6 else { // 4 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(-1.0f, 0.0f); } // 6 else { localContactPoint.Point = new Vector2(boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(1.0f, 0.0f); } } } return localContactPoint; } private static ContactPoint CalcCircleContactPoint(Vector2 wallCentre, CircleCollider wall, Vector2 position) { ContactPoint contactPoint = default(ContactPoint); contactPoint.Normal = (position - wallCentre).normalized; contactPoint.Point = wallCentre + wall.Radius * contactPoint.Normal; return contactPoint; } } } 


. O que vem a seguir?


, , - , . ECS . , , JSON, ECS. :



, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .

- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .

- , 3D-, , .

, , , . , , ECS .

Links úteis


:


:

Source: https://habr.com/ru/post/pt481880/


All Articles