Como melloramos a mecánica dos cálculos balísticos para un tirador móbil cun algoritmo de compensación de latencia de rede

Como melloramos a mecánica dos cálculos balísticos para un tirador móbil cun algoritmo de compensación de latencia de rede

Ola, son Nikita Brizhak, unha desenvolvedora de servidores de Pixonic. Hoxe gustaríame falar sobre a compensación do desfase no multixogador móbil.

Escribíronse moitos artigos sobre a compensación do atraso do servidor, incluso en ruso. Isto non é sorprendente, xa que esta tecnoloxía utilizouse activamente na creación de FPS multixogador desde finais dos 90. Por exemplo, podes lembrar o mod QuakeWorld, que foi un dos primeiros en usalo.

Tamén o usamos no noso xogo de disparos multixogador móbil Dino Squad.

Neste artigo, o meu obxectivo non é repetir o que xa se escribiu mil veces, senón contar como implementamos a compensación de atraso no noso xogo, tendo en conta a nosa pila de tecnoloxía e as características básicas do xogo.

Algunhas palabras sobre a nosa cortiza e tecnoloxía.

Dino Squad é un tirador PvP móbil en rede. Os xogadores controlan dinosauros equipados cunha variedade de armas e loitan entre eles en equipos 6v6.

Tanto o cliente como o servidor baséanse en Unity. A arquitectura é bastante clásica para os tiradores: o servidor é autoritario e a predición do cliente funciona nos clientes. A simulación do xogo está escrita usando ECS interno e úsase tanto no servidor como no cliente.

Se esta é a primeira vez que escoitas falar da compensación por atraso, aquí tes unha breve excursión ao problema.

Nos xogos FPS multixogador, a partida adoita simularse nun servidor remoto. Os xogadores envían a súa entrada (información sobre as teclas pulsadas) ao servidor e, como resposta, o servidor envíalles un estado de xogo actualizado tendo en conta os datos recibidos. Con este esquema de interacción, o atraso entre a pulsación da tecla de avance e o momento en que o personaxe do xogador se move na pantalla será sempre maior que o ping.

Mentres nas redes locais este atraso (popularmente chamado atraso de entrada) pode ser imperceptible, cando se xoga a través de Internet crea unha sensación de "deslizamento sobre o xeo" ao controlar un personaxe. Este problema é dobremente relevante para as redes móbiles, onde o caso en que o ping dun xogador é de 200 ms aínda se considera unha conexión excelente. Moitas veces o ping pode ser de 350, 500 ou 1000 ms. Entón faise case imposible xogar a un tirador rápido con atraso de entrada.

A solución a este problema é a predición de simulación do lado do cliente. Aquí o propio cliente aplica a entrada ao personaxe do xogador, sen esperar a resposta do servidor. E cando se recibe a resposta, simplemente compara os resultados e actualiza as posicións dos opoñentes. O atraso entre a pulsación dunha tecla e a visualización do resultado na pantalla neste caso é mínimo.

É importante comprender o matiz aquí: o cliente sempre se debuxa segundo a súa última entrada e os inimigos - con atraso de rede, segundo o estado anterior dos datos do servidor. É dicir, ao disparar a un inimigo, o xogador veo no pasado en relación a si mesmo. Máis información sobre a predición do cliente escribimos antes.

Así, a predición do cliente resolve un problema, pero crea outro: se un xogador dispara no punto onde estaba o inimigo no pasado, no servidor ao disparar no mesmo punto, é posible que o inimigo xa non estea nese lugar. A compensación do atraso do servidor tenta resolver este problema. Cando se dispara unha arma, o servidor restablece o estado de xogo que o xogador viu localmente no momento do disparo e comproba se realmente puido golpear ao inimigo. Se a resposta é "si", cóntase o golpe, aínda que o inimigo xa non estea no servidor nese momento.

Armados con este coñecemento, comezamos a implementar a compensación do atraso do servidor en Dino Squad. En primeiro lugar, tivemos que entender como restaurar no servidor o que viu o cliente? E que hai que restaurar exactamente? No noso xogo, os golpes de armas e habilidades calcúlanse mediante raycasts e superposicións, é dicir, mediante interaccións cos colisionadores físicos do inimigo. En consecuencia, necesitabamos reproducir a posición destes colisionadores, que o xogador "viu" localmente, no servidor. Nese momento estabamos a usar a versión 2018.x de Unity. A API de física alí é estática, o mundo físico existe nunha única copia. Non hai forma de gardar o seu estado e despois restauralo desde a caixa. Entón, que facer?

A solución estaba na superficie; todos os seus elementos xa foran empregados por nós para resolver outros problemas:

  1. Para cada cliente, necesitamos saber a que hora viu aos adversarios cando premeu as teclas. Xa escribimos esta información no paquete de entrada e utilizámola para axustar a predición do cliente.
  2. Necesitamos ser capaces de almacenar o historial dos estados do xogo. É nela onde manteremos as posicións dos nosos adversarios (e, polo tanto, dos seus colisionadores). Xa tiñamos un historial de estado no servidor, utilizámolo para construír deltas. Coñecendo o momento axeitado, poderiamos atopar facilmente o estado correcto na historia.
  3. Agora que temos o estado do xogo da historia na man, temos que ser capaces de sincronizar os datos do xogador co estado do mundo físico. Os colisionadores existentes - mover, os que faltan - crear, os innecesarios - destruír. Esta lóxica tamén estaba xa escrita e constaba de varios sistemas ECS. Usámolo para albergar varias salas de xogos nun proceso de Unity. E como o mundo físico é un por proceso, tivo que ser reutilizado entre salas. Antes de cada tick da simulación, "restablecemos" o estado do mundo físico e reiniciámolo con datos para a sala actual, tentando reutilizar os obxectos do xogo Unity o máximo posible mediante un intelixente sistema de posta en común. Só restaba invocar a mesma lóxica para o estado do xogo do pasado.

Ao xuntarse todos estes elementos, obtivemos unha "máquina do tempo" que podería retrotraer o estado do mundo físico ao momento adecuado. O código resultou sinxelo:

public class TimeMachine : ITimeMachine
{
     //История игровых состояний
     private readonly IGameStateHistory _history;

     //Текущее игровое состояние на сервере
     private readonly ExecutableSystem[] _systems;

     //Набор систем, расставляющих коллайдеры в физическом мире 
     //по данным из игрового состояния
     private readonly GameState _presentState;

     public TimeMachine(IGameStateHistory history, GameState presentState, ExecutableSystem[] timeInitSystems)
     {
         _history = history; 
         _presentState = presentState;
         _systems = timeInitSystems;  
     }

     public GameState TravelToTime(int tick)
     {
         var pastState = tick == _presentState.Time ? _presentState : _history.Get(tick);
         foreach (var system in _systems)
         {
             system.Execute(pastState);
         }
         return pastState;
     }
}

Todo o que quedaba era descubrir como usar esta máquina para compensar facilmente tiros e habilidades.

No caso máis sinxelo, cando a mecánica se basea nun único hitscan, todo parece estar claro: antes de que o xogador tire, ten que retroceder o mundo físico ao estado desexado, facer un raycast, contar o acerto ou o fallo e devolver o mundo ao estado inicial.

Pero hai moi poucas mecánicas deste tipo en Dino Squad! A maioría das armas do xogo crean proxectís: balas de longa duración que voan por varias garrapatas de simulación (nalgúns casos, decenas de garrapatas). Que facer con eles, a que hora deberían voar?

В artigo antigo Sobre a pila de rede Half-Life, os mozos de Valve fixeron a mesma pregunta e a súa resposta foi a seguinte: a compensación do atraso do proxectil é problemática e é mellor evitalo.

Non tiñamos esta opción: as armas baseadas en proxectís eran unha característica fundamental do deseño do xogo. Entón tivemos que chegar a algo. Despois dunha pequena chuvia de ideas, formulamos dúas opcións que parecían funcionar:

1. Atamos o proxectil á hora do xogador que o creou. Cada tick da simulación do servidor, por cada bala de cada xogador, retrocedemos o mundo físico ao estado do cliente e realizamos os cálculos necesarios. Este enfoque permitiu ter unha carga distribuída no servidor e un tempo de voo previsible dos proxectís. A previsibilidade foi especialmente importante para nós, xa que temos todos os proxectís, incluídos os proxectís inimigos, previstos no cliente.

Como melloramos a mecánica dos cálculos balísticos para un tirador móbil cun algoritmo de compensación de latencia de rede
Na imaxe, o xogador do tick 30 dispara un mísil con anticipación: ve en que dirección corre o inimigo e coñece a velocidade aproximada do mísil. Localmente ve que acertou o obxectivo no tic 33. Grazas á compensación de atraso, tamén aparecerá no servidor

2. Facemos todo o mesmo que na primeira opción, pero, contado un tick da simulación de bala, non paramos, senón que seguimos simulando o seu voo dentro do mesmo tick do servidor, achegando cada vez o seu tempo ao servidor. un por un marcar e actualizar as posicións do colisionador. Facemos isto ata que suceda unha de dúas cousas:

  • A bala caducou. Isto significa que os cálculos remataron, podemos contar un fallo ou un acerto. E isto é no mesmo tic no que se fixo o tiro! Para nós isto foi tanto un plus como un menos. Unha vantaxe - porque para o xogador que dispara, isto reduciu significativamente o atraso entre o golpe e a diminución da saúde do inimigo. O inconveniente é que se observou o mesmo efecto cando os adversarios disparaban contra o xogador: o inimigo, ao parecer, só disparou un foguete lento, e o dano xa estaba contado.
  • A viñeta chegou á hora do servidor. Neste caso, a súa simulación continuará no seguinte tick do servidor sen compensación de atraso. Para proxectís lentos, isto podería reducir teoricamente o número de retrocesos da física en comparación coa primeira opción. Ao mesmo tempo, aumentou a carga desigual na simulación: o servidor estaba inactivo ou nun tick do servidor calculaba unha ducia de ticks de simulación para varias balas.

Como melloramos a mecánica dos cálculos balísticos para un tirador móbil cun algoritmo de compensación de latencia de rede
O mesmo escenario que na imaxe anterior, pero calculado segundo o segundo esquema. O mísil "alcanzou" o tempo do servidor no mesmo tic en que se produciu o disparo, e o impacto pódese contar xa no seguinte tic. No 31º tick, neste caso, xa non se aplica a compensación de atraso

Na nosa implementación, estes dous enfoques diferían en só un par de liñas de código, polo que creamos ambos, e durante moito tempo existiron en paralelo. Dependendo da mecánica da arma e da velocidade da bala, escollemos unha ou outra opción para cada dinosauro. O punto de inflexión aquí foi a aparición no xogo de mecánicas como "se golpeas ao inimigo tantas veces en tal momento, obtén tal ou cal bonificación". Calquera mecánico onde o momento no que o xogador golpeou o inimigo xogase un papel importante negouse a traballar coa segunda aproximación. Así que acabamos coa primeira opción, e agora aplícase a todas as armas e a todas as habilidades activas do xogo.

Por separado, paga a pena plantexar a cuestión do rendemento. Se pensabas que todo isto ralentizaría as cousas, eu contesto: é. Unity é bastante lenta ao mover os colisionadores e acendelos e apagalos. En Dino Squad, no "peor" dos casos, pode haber varios centos de proxectís existentes simultaneamente en combate. Mover colisionadores para contar cada proxectil individualmente é un luxo inasequible. Polo tanto, era absolutamente necesario para nós minimizar o número de "retrocesos" da física. Para iso, creamos un compoñente separado en ECS no que gravamos o tempo do xogador. Engadímolo a todas as entidades que requiran compensación de atraso (proxectos, habilidades, etc.). Antes de comezar a procesar tales entidades, agrupámolas neste momento e procesámolas xuntas, retrotraendo o mundo físico unha vez por cada clúster.

Nesta fase temos un sistema de traballo xeral. O seu código nunha forma algo simplificada:

public sealed class LagCompensationSystemGroup : ExecutableSystem
{
     //Машина времени
     private readonly ITimeMachine _timeMachine;

     //Набор систем лагкомпенсации
     private readonly LagCompensationSystem[] _systems;
     
     //Наша реализация кластеризатора
     private readonly TimeTravelMap _travelMap = new TimeTravelMap();

    public LagCompensationSystemGroup(ITimeMachine timeMachine, 
        LagCompensationSystem[] lagCompensationSystems)
     {
         _timeMachine = timeMachine;
         _systems = lagCompensationSystems;
     }

     public override void Execute(GameState gs)
     {
         //На вход кластеризатор принимает текущее игровое состояние,
         //а на выход выдает набор «корзин». В каждой корзине лежат энтити,
         //которым для лагкомпенсации нужно одно и то же время из истории.
         var buckets = _travelMap.RefillBuckets(gs);

         for (int bucketIndex = 0; bucketIndex < buckets.Count; bucketIndex++)
         {
             ProcessBucket(gs, buckets[bucketIndex]);
         }

         //В конце лагкомпенсации мы восстанавливаем физический мир 
         //в исходное состояние
         _timeMachine.TravelToTime(gs.Time);
     }

     private void ProcessBucket(GameState presentState, TimeTravelMap.Bucket bucket)
     {
         //Откатываем время один раз для каждой корзины
         var pastState = _timeMachine.TravelToTime(bucket.Time);

         foreach (var system in _systems)
         {
               system.PastState = pastState;
               system.PresentState = presentState;

               foreach (var entity in bucket)
               {
                   system.Execute(entity);
               }
          }
     }
}

Só restaba configurar os detalles:

1. Comprender canto limitar a distancia máxima de movemento no tempo.

Era importante para nós facer o xogo o máis accesible posible en condicións de malas redes móbiles, polo que limitamos a historia cunha marxe de 30 ticks (cunha taxa de tick de 20 Hz). Isto permite aos xogadores golpear aos opoñentes mesmo con pings moi altos.

2. Determina que obxectos se poden mover no tempo e cales non.

Nós, por suposto, estamos movendo os nosos opoñentes. Pero os escudos de enerxía instalables, por exemplo, non o son. Decidimos que era mellor darlle prioridade á capacidade defensiva, como adoita facerse nos tiradores en liña. Se o xogador xa colocou un escudo no presente, as balas compensadas con retardo do pasado non deberían voar a través del.

3. Decidir se é necesario compensar as habilidades dos dinosauros: morder, golpear a cola, etc. Decidimos o que facía falta e procesámolos seguindo as mesmas regras que as balas.

4. Determine que facer cos colisionadores do xogador para o que se está a realizar a compensación de atraso. En boa forma, a súa posición non debe cambiar ao pasado: o xogador debe verse a si mesmo no mesmo tempo no que está agora no servidor. Non obstante, tamén recuperamos os colisionadores do xogador que dispara, e hai varias razóns para iso.

En primeiro lugar, mellora a agrupación: podemos usar o mesmo estado físico para todos os xogadores con pings próximos.

En segundo lugar, en todos os raycasts e superposicións sempre excluímos os colisionadores do xogador que posúe as habilidades ou proxectís. En Dino Squad, os xogadores controlan os dinosauros, que teñen unha xeometría bastante non estándar para os estándares dos tiradores. Aínda que o xogador dispara nun ángulo inusual e a traxectoria da bala pasa polo colisionador de dinosauros do xogador, a bala ignorarao.

En terceiro lugar, calculamos as posicións da arma do dinosauro ou o punto de aplicación da habilidade utilizando datos do ECS mesmo antes do inicio da compensación do atraso.

Como resultado, a posición real dos colisionadores do xogador compensado por desfase non ten importancia para nós, polo que tomamos un camiño máis produtivo e ao mesmo tempo máis sinxelo.

A latencia da rede non se pode eliminar simplemente, só se pode enmascarar. Como calquera outro método de disfraz, a compensación do atraso do servidor ten os seus beneficios. Mellora a experiencia de xogo do xogador que está a disparar a costa do xogador ao que lle disparan. Para Dino Squad, con todo, a elección aquí era obvia.

Por suposto, todo isto tamén tivo que ser pagado pola maior complexidade do código do servidor no seu conxunto, tanto para programadores como para deseñadores de xogos. Se antes a simulación era unha simple chamada secuencial de sistemas, entón, con compensación de atraso, apareceron nela bucles aniñados e ramas. Tamén esforzámonos moito para que fose cómodo traballar.

Na versión de 2019 (e quizais un pouco antes), Unity engadiu un soporte total para escenas físicas independentes. Implementámolos no servidor case inmediatamente despois da actualización, porque queriamos desfacernos rapidamente do mundo físico común a todas as salas.

Dámoslle a cada sala de xogos a súa propia escena física e así eliminamos a necesidade de "borrar" a escena dos datos da sala veciña antes de calcular a simulación. En primeiro lugar, deu un aumento significativo da produtividade. En segundo lugar, permitiu desfacerse de toda unha clase de erros que xurdían se o programador cometeu un erro no código de limpeza da escena ao engadir novos elementos do xogo. Tales erros eran difíciles de depurar, e moitas veces provocaban que o estado dos obxectos físicos da escena dunha sala "fluíse" cara a outra.

Ademais, investigamos se as escenas físicas podían usarse para almacenar a historia do mundo físico. É dicir, de forma condicional, non asignar unha escena a cada sala, senón 30 escenas, e facer con elas un búfer cíclico, no que almacenar a historia. En xeral, a opción resultou funcionar, pero non a implementamos: non mostrou ningún aumento tolo da produtividade, senón que requiriu cambios bastante arriscados. Era difícil predicir como se comportaría o servidor cando se traballaba durante moito tempo con tantas escenas. Polo tanto, seguimos a regra: "Se non se rompe, non o arranxes».

Fonte: www.habr.com

Engadir un comentario