Kiel ni plibonigis la mekanikon de balistikaj kalkuloj por movebla pafisto kun algoritmo de kompensa latenteco de reto

Kiel ni plibonigis la mekanikon de balistikaj kalkuloj por movebla pafisto kun algoritmo de kompensa latenteco de reto

Saluton, mi estas Nikita Brizhak, servila programisto de Pixonic. Hodiaŭ mi ŝatus paroli pri kompenso de malfruo en poŝtelefona multiludanto.

Multaj artikoloj estis skribitaj pri servila malfrua kompenso, inkluzive en la rusa. Ĉi tio ne estas surpriza, ĉar ĉi tiu teknologio estis aktive uzata en la kreado de multiludantaj FPS ekde la malfruaj 90-aj jaroj. Ekzemple, vi povas memori la QuakeWorld-modon, kiu estis unu el la unuaj, kiuj uzis ĝin.

Ni ankaŭ uzas ĝin en nia movebla plurludanta pafisto Dino Squad.

En ĉi tiu artikolo, mia celo ne estas ripeti tion, kio jam estis skribita milfoje, sed rakonti kiel ni efektivigis malfruan kompenson en nia ludo, konsiderante nian teknologian stakon kaj kernajn ludajn funkciojn.

Kelkajn vortojn pri nia kortekso kaj teknologio.

Dino Squad estas reto movebla PvP-pafisto. Ludantoj kontrolas dinosaŭrojn ekipitajn per diversaj armiloj kaj batalas unu la alian en 6v6 teamoj.

Kaj la kliento kaj la servilo baziĝas sur Unity. La arkitekturo estas sufiĉe klasika por pafistoj: la servilo estas aŭtoritata, kaj klienta prognozo funkcias sur la klientoj. La ludsimulado estas skribita uzante endoman ECS kaj estas uzita sur kaj la servilo kaj kliento.

Se ĉi tio estas la unua fojo, kiam vi aŭdis pri malfrua kompenso, jen mallonga ekskurso pri la afero.

En plurludantaj FPS-ludoj, la matĉo estas kutime simulita sur fora servilo. Ludantoj sendas sian enigaĵon (informojn pri la klavoj premitaj) al la servilo, kaj en respondo la servilo sendas al ili ĝisdatigitan ludstato konsiderante la ricevitajn datumojn. Kun ĉi tiu interaga skemo, la prokrasto inter premado de la antaŭenklavo kaj la momento, kiam la ludanta karaktero moviĝas sur la ekranon, ĉiam estos pli granda ol la ping.

Dum en lokaj retoj ĉi tiu malfruo (populare nomata enigmalfruo) povas esti nerimarkebla, dum ludado per Interreto ĝi kreas senton de "gliti sur glacio" kiam oni kontrolas rolulon. Ĉi tiu problemo estas duoble grava por moveblaj retoj, kie la kazo, kiam la ping de ludanto estas 200 ms, ankoraŭ estas konsiderata bonega konekto. Ofte la ping povas esti 350, 500 aŭ 1000 ms. Tiam fariĝas preskaŭ neeble ludi rapidan pafiston kun eniga malfruo.

La solvo al tiu problemo estas klientflanka simuladprognozo. Ĉi tie la kliento mem aplikas la enigon al la ludanto, sen atendi respondon de la servilo. Kaj kiam la respondo estas ricevita, ĝi simple komparas la rezultojn kaj ĝisdatigas la poziciojn de la kontraŭuloj. La prokrasto inter premado de klavo kaj montrado de la rezulto sur la ekrano ĉi-kaze estas minimuma.

Gravas kompreni la nuancon ĉi tie: la kliento ĉiam desegnas sin laŭ sia lasta enigo, kaj malamikoj - kun reto prokrasto, laŭ la antaŭa stato de la datumoj de la servilo. Tio estas, kiam pafado al malamiko, la ludanto vidas lin en la pasinteco relative al li mem. Pli pri klienta prognozo ni skribis pli frue.

Tiel, klientprognozo solvas unu problemon, sed kreas alian: se ludanto pafas ĉe la punkto kie la malamiko estis en la pasinteco, sur la servilo dum pafado ĉe la sama punkto, la malamiko eble ne plu estas en tiu loko. Servila malfrua kompenso provas solvi ĉi tiun problemon. Kiam armilo estas pafita, la servilo reestigas la ludŝtaton kiun la ludanto vidis loke dum la pafo, kaj kontrolas ĉu li vere povus esti trafinta la malamikon. Se la respondo estas "jes", la sukceso estas nombrita, eĉ se la malamiko ne plu estas sur la servilo ĉe tiu punkto.

Armitaj kun ĉi tiu scio, ni komencis efektivigi servilan malfruan kompenson en Dino Squad. Antaŭ ĉio, ni devis kompreni kiel restarigi sur la servilo tion, kion la kliento vidis? Kaj kio precize devas esti restarigita? En nia ludo, sukcesoj de armiloj kaj kapabloj estas kalkulitaj per radioelsendo kaj supermetaĵoj - tio estas, per interagoj kun la fizikaj koliziiloj de la malamiko. Sekve, ni bezonis reprodukti la pozicion de ĉi tiuj koliziiloj, kiujn la ludanto "vidis" loke, sur la servilo. Tiutempe ni uzis Unity-version 2018.x. La fizika API tie estas senmova, la fizika mondo ekzistas en ununura kopio. Ne estas maniero savi ĝian staton kaj poste restarigi ĝin el la skatolo. Kion do fari?

La solvo estis sur la surfaco; ĉiuj ĝiaj elementoj jam estis uzitaj de ni por solvi aliajn problemojn:

  1. Por ĉiu kliento, ni devas scii je kioma horo li vidis kontraŭulojn kiam li premis la klavojn. Ni jam skribis ĉi tiujn informojn en la enigpakaĵon kaj uzis ĝin por ĝustigi la klientan prognozon.
  2. Ni devas povi konservi la historion de ludŝtatoj. Ĝuste en ĝi ni tenos la poziciojn de niaj kontraŭuloj (kaj do iliaj koliziantoj). Ni jam havis ŝtatan historion sur la servilo, ni uzis ĝin por konstrui deltoj. Konante la ĝustan tempon, ni facile povus trovi la ĝustan staton en la historio.
  3. Nun kiam ni havas la ludstato de historio en la mano, ni devas povi sinkronigi ludantoj datumoj kun la stato de la fizika mondo. Ekzistantaj koliziiloj - movi, mankantaj - krei, nenecesaj - detrui. Ĉi tiu logiko ankaŭ estis jam skribita kaj konsistis el pluraj ECS-sistemoj. Ni uzis ĝin por teni plurajn ludĉambrojn en unu Unity-procezo. Kaj ĉar la fizika mondo estas unu por procezo, ĝi devis esti reuzita inter ĉambroj. Antaŭ ĉiu tiktako de la simulado, ni "rekomencigis" la staton de la fizika mondo kaj rekomencigis ĝin kun datumoj por la nuna ĉambro, provante reuzi Unity-ludajn objektojn kiel eble plej multe per lerta kunigsistemo. Restis nur alvoki la saman logikon por la ludstato de la pasinteco.

Kunigante ĉiujn ĉi tiujn elementojn, ni akiris "tempomaŝinon", kiu povus retrorigi la staton de la fizika mondo al la ĝusta momento. La kodo montriĝis simpla:

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;
     }
}

Restis nur eltrovi kiel uzi ĉi tiun maŝinon por facile kompensi pafojn kaj kapablojn.

En la plej simpla kazo, kiam la mekaniko baziĝas sur ununura trafskanado, ĉio ŝajnas esti klara: antaŭ ol la ludanto pafas, li devas revenigi la fizikan mondon al la dezirata stato, fari radioelsendon, nombri la sukceson aŭ maltrafon, kaj redonu la mondon al la komenca stato.

Sed estas tre malmultaj tiaj mekanikistoj en Dino Squad! La plej multaj el la armiloj en la ludo kreas ĵetaĵojn - longdaŭrajn kuglojn, kiuj flugas por pluraj simulaj iksodoj (en iuj kazoj, dekoj da iksodoj). Kion fari kun ili, je kioma horo ili flugu?

В antikva artikolo pri la reto-stako Half-Life, la uloj de Valve faris la saman demandon, kaj ilia respondo estis jena: kompenso pri ĵetaĵo malfruo estas problema, kaj estas pli bone eviti ĝin.

Ni ne havis ĉi tiun opcion: kuglo-bazitaj armiloj estis ŝlosila trajto de la luddezajno. Do ni devis elpensi ion. Post iom da cerbumado, ni formulis du eblojn, kiuj ŝajnis funkcii:

1. Ni ligas la ĵetaĵon al la tempo de la ludanto kiu kreis ĝin. Ĉiu tiktako de la servila simulado, por ĉiu kuglo de ĉiu ludanto, ni retroiras la fizikan mondon al la klienta stato kaj faras la necesajn kalkulojn. Ĉi tiu aliro ebligis havi distribuitan ŝarĝon sur la servilo kaj antaŭvidebla flugtempo de ĵetaĵoj. Antaŭvidebleco estis precipe grava por ni, ĉar ni havas ĉiujn ĵetaĵojn, inkluzive de malamikaj ĵetaĵoj, antaŭdiritaj sur la kliento.

Kiel ni plibonigis la mekanikon de balistikaj kalkuloj por movebla pafisto kun algoritmo de kompensa latenteco de reto
На картинке игрок в 30-ом тике стреляет ракетой на упреждение: он видит, в каком направлении бежит противник, и знает примерную скорость ракеты. Локально он видит, что попал в цель в 33-ем тике. Благодаря лагкомпенсации попадет он и на сервере

2. Ni faras ĉion same kiel en la unua opcio, sed, kalkulinte unu tick de la kuglo-simulado, ni ne ĉesas, sed daŭre simulas ĝian flugon ene de la sama servila tick, ĉiufoje proksimigante sian tempon al la servilo. unu post alia tick kaj ĝisdatigo de koliziilpozicioj. Ni faras tion ĝis unu el du aferoj okazas:

  • La kuglo eksvalidiĝis. Ĉi tio signifas, ke la kalkuloj finiĝis, ni povas kalkuli maltrafon aŭ trafon. Kaj ĉi tio estas ĉe la sama ikso, en kiu la pafo estis pafita! Por ni ĉi tio estis kaj pluso kaj minuso. Pluso - ĉar por la pafanta ludanto ĉi tio signife reduktis la prokraston inter la trafo kaj la malkresko de la sano de la malamiko. La malavantaĝo estas, ke la sama efiko estis observita kiam kontraŭuloj pafis al la ludanto: la malamiko, ŝajne, pafis nur malrapidan raketon, kaj la damaĝo jam estis kalkulita.
  • La kuglo atingis servilan tempon. En ĉi tiu kazo, ĝia simulado daŭros en la sekva servila tick sen ia malfrua kompenso. Por malrapidaj kugloj, tio teorie povus redukti la nombron da fizikaj retrovojoj kompare kun la unua opcio. Samtempe, la neegala ŝarĝo sur la simulado pliiĝis: la servilo estis aŭ neaktiva, aŭ en unu servila tiktako ĝi kalkulis dekduon de simulaj tiktakoj por pluraj kugloj.

Kiel ni plibonigis la mekanikon de balistikaj kalkuloj por movebla pafisto kun algoritmo de kompensa latenteco de reto
La sama scenaro kiel en la antaŭa bildo, sed kalkulita laŭ la dua skemo. La misilo "kaptis" kun la servila tempo je la sama tiktako ke la pafo okazis, kaj la trafo povas esti nombrita jam la venonta tiktako. Ĉe la 31-a iksodo, en ĉi tiu kazo, malfrua kompenso ne plu estas aplikata

En nia efektivigo, ĉi tiuj du aliroj diferencis en nur kelkaj linioj de kodo, do ni kreis ambaŭ, kaj dum longa tempo ili ekzistis paralele. Depende de la mekaniko de la armilo kaj la rapideco de la kuglo, ni elektis unu aŭ alian eblon por ĉiu dinosaŭro. La turnopunkto ĉi tie estis la apero en la ludo de mekaniko kiel "se vi trafas la malamikon tiom da fojoj en tia kaj tia tempo, ricevu tian kaj tian gratifikon." Ajna mekanikisto, kie la tempo, kiam la ludanto trafis la malamikon, ludis gravan rolon, rifuzis labori kun la dua aliro. Do ni finis iri kun la unua opcio, kaj ĝi nun validas por ĉiuj armiloj kaj ĉiuj aktivaj kapabloj en la ludo.

Aparte, indas levi la temon de agado. Se vi pensis, ke ĉio ĉi malrapidigos la aferojn, mi respondas: jes. Unueco estas sufiĉe malrapida en movi koliziilojn kaj enŝalti kaj malŝalti ilin. En Dino Squad, en la "plej malbona" ​​kazo, povas ekzisti kelkcent kugloj ekzistantaj samtempe en batalo. Movi koliziilojn por nombri ĉiun kuglon individue estas neatingebla lukso. Tial, estis absolute necese por ni minimumigi la nombron da fizikaj "retrovoj". Por fari tion, ni kreis apartan komponanton en ECS, en kiu ni registras la tempon de la ludanto. Ni aldonis ĝin al ĉiuj estaĵoj kiuj postulas malfruan kompenson (projektiloj, kapabloj, ktp.). Antaŭ ol ni komencas prilabori tiajn entojn, ni amasigas ilin ĝis nun kaj prilaboras ilin kune, retroirante la fizikan mondon unufoje por ĉiu areto.

En ĉi tiu etapo ni havas ĝenerale funkciantan sistemon. Ĝia kodo en iom simpligita formo:

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);
               }
          }
     }
}

Restis nur agordi la detalojn:

1. Komprenu kiom limigi la maksimuman distancon de movado en la tempo.

Estis grave por ni fari la ludon kiel eble plej alirebla en kondiĉoj de malriĉaj moveblaj retoj, do ni limigis la rakonton kun marĝeno de 30 tiktakoj (kun tiktakofteco de 20 Hz). Ĉi tio permesas al ludantoj trafi kontraŭulojn eĉ ĉe tre altaj ping-oj.

2. Determini kiuj objektoj povas esti movitaj en tempo kaj kiuj ne povas.

Ni, kompreneble, movas niajn kontraŭulojn. Sed instaleblaj energiaj ŝildoj, ekzemple, ne estas. Ni decidis, ke estas pli bone doni prioritaton al la defenda kapablo, kiel oni ofte faras en interretaj pafistoj. Se la ludanto jam metis ŝildon en la nuntempo, lag-kompensitaj kugloj de la pasinteco ne devus flugi tra ĝi.

3. Decidi ĉu necesas kompensi la kapablecojn de la dinosaŭroj: mordi, vostobaton, ktp. Ni decidis, kio estas bezonata kaj prilabori ilin laŭ la samaj reguloj kiel kugloj.

4. Determini kion fari kun la koliziantoj de la ludanto por kiu malfrua kompenso estas farita. En bona maniero, ilia pozicio ne devus ŝanĝiĝi en la pasintecon: la ludanto devus vidi sin en la sama tempo en kiu li nun estas sur la servilo. Tamen ni ankaŭ renversas la koliziilojn de la pafanta ludanto, kaj ekzistas pluraj kialoj por tio.

Unue, ĝi plibonigas clustering: ni povas uzi la saman fizikan staton por ĉiuj ludantoj kun proksimaj ping-oj.

Due, en ĉiuj radielsendoj kaj interkovroj ni ĉiam ekskludas la koliziilojn de la ludanto, kiu posedas la kapablojn aŭ ĵetaĵojn. En Dino Squad, ludantoj kontrolas dinosaŭrojn, kiuj havas sufiĉe nenorman geometrion laŭ pafistonormoj. Eĉ se la ludanto pafas laŭ nekutima angulo kaj la trajektorio de la kuglo pasas tra la dinosaŭrokolizio de la ludanto, la kuglo ignoros ĝin.

Trie, ni kalkulas la poziciojn de la armilo de la dinosaŭro aŭ la punkton de apliko de la kapablo uzante datumojn de la ECS eĉ antaŭ la komenco de malfrua kompenso.

Kiel rezulto, la reala pozicio de la koliziantoj de la lag-kompensita ludanto estas negrava por ni, do ni prenis pli produktivan kaj samtempe pli simplan vojon.

Reta latenco ne povas simple esti forigita, ĝi povas nur esti maskita. Kiel ajna alia metodo de alivestiĝo, servila malfrua kompenso havas siajn kompromisojn. Ĝi plibonigas la ludsperton de la ludanto, kiu pafas koste de la ludanto, kiun oni pafas. Por Dino Squad, tamen, la elekto ĉi tie estis evidenta.

Kompreneble, ĉio ĉi ankaŭ devis esti pagita per la pliigita komplekseco de la servilkodo entute - kaj por programistoj kaj luddezajnistoj. Se pli frue la simulado estis simpla sinsekva alvoko de sistemoj, tiam kun malfrua kompenso aperis en ĝi nestitaj bukloj kaj branĉoj. Ni ankaŭ elspezis multan penon por igi ĝin oportuna labori kun.

En la versio de 2019 (kaj eble iom pli frue), Unity aldonis plenan subtenon por sendependaj fizikaj scenoj. Ni efektivigis ilin en la servilo preskaŭ tuj post la ĝisdatigo, ĉar ni volis rapide forigi la fizikan mondon komunan al ĉiuj ĉambroj.

Ni donis al ĉiu ludĉambro sian propran fizikan scenon kaj tiel forigis la bezonon "malbari" la scenon el la datumoj de la najbara ĉambro antaŭ ol kalkuli la simuladon. Unue, ĝi donis signifan kreskon de produktiveco. Due, ĝi ebligis forigi tutan klason de cimoj, kiuj estiĝis se la programisto faris eraron en la scenpurigkodo aldonante novajn ludelementojn. Tiajn erarojn estis malfacile sencimeblaj, kaj ili ofte rezultigis la staton de fizikaj objektoj en la sceno de unu ĉambro "fluanta" en alian ĉambron.

Krome, ni esploris ĉu fizikaj scenoj povus esti uzataj por konservi la historion de la fizika mondo. Tio estas, kondiĉe, asignu ne unu scenon al ĉiu ĉambro, sed 30 scenojn, kaj faru ciklan bufron el ili, en kiu stoki la rakonton. Ĝenerale, la opcio rezultis funkcii, sed ni ne efektivigis ĝin: ĝi ne montris frenezan pliiĝon de produktiveco, sed postulis sufiĉe riskajn ŝanĝojn. Estis malfacile antaŭdiri, kiel la servilo kondutos dum longa tempo laborante kun tiom da scenoj. Tial ni sekvis la regulon: "Se ĝi ne rompiĝas, ne riparu ĝin".

fonto: www.habr.com

Aldoni komenton