Kaip patobulinome mobiliojo šaulio balistinių skaičiavimų mechaniką su tinklo delsos kompensavimo algoritmu

Kaip patobulinome mobiliojo šaulio balistinių skaičiavimų mechaniką su tinklo delsos kompensavimo algoritmu

Sveiki, aš Nikita Brizhak, serverio kūrėjas iš Pixonic. Šiandien norėčiau pakalbėti apie atsilikimo kompensavimą naudojant kelių žaidėjų mobiliuosius įrenginius.

Apie serverio vėlavimo kompensavimą parašyta daug straipsnių, įskaitant rusų kalba. Tai nenuostabu, nes ši technologija buvo aktyviai naudojama kuriant kelių žaidėjų FPS nuo 90-ųjų pabaigos. Pavyzdžiui, galite prisiminti „QuakeWorld“ modifikaciją, kuri buvo viena pirmųjų jį panaudojusi.

Taip pat naudojame jį mobiliajame kelių žaidėjų šaudyklėje „Dino Squad“.

Šiame straipsnyje mano tikslas yra ne kartoti tai, kas jau buvo parašyta tūkstantį kartų, o papasakoti, kaip mes įdiegėme atsilikimo kompensavimą savo žaidime, atsižvelgdami į mūsų technologijų krūvą ir pagrindines žaidimo funkcijas.

Keletas žodžių apie mūsų žievę ir technologijas.

Dino Squad yra tinklo mobilusis PvP šaulys. Žaidėjai valdo dinozaurus, aprūpintus įvairiais ginklais, ir kovoja tarpusavyje 6 prieš 6 komandose.

Tiek klientas, tiek serveris yra pagrįsti Unity. Architektūra yra gana klasikinė šauliams: serveris yra autoritarinis, o klientų numatymas veikia klientus. Žaidimo modeliavimas yra parašytas naudojant vidinį ECS ir naudojamas tiek serveryje, tiek kliente.

Jei apie atsilikimo kompensavimą girdite pirmą kartą, čia pateikiama trumpa apžvalga.

Kelių žaidėjų FPS žaidimuose rungtynės dažniausiai imituojamos nuotoliniame serveryje. Žaidėjai siunčia savo įvestį (informaciją apie paspaustus klavišus) į serverį, o atsakydamas serveris siunčia jiems atnaujintą žaidimo būseną, atsižvelgdamas į gautus duomenis. Taikant šią sąveikos schemą, delsa nuo mygtuko pirmyn paspaudimo iki žaidėjo simbolio pajudėjimo ekrane visada bus didesnė nei ping.

Nors vietiniuose tinkluose šis delsimas (liaudiškai vadinamas įvesties delsa) gali būti nepastebimas, žaidžiant internetu jis sukuria „slydimo ant ledo“ jausmą valdant veikėją. Ši problema dvigubai aktuali mobiliojo ryšio tinklams, kur atvejis, kai žaidėjo ping yra 200 ms, vis dar laikomas puikiu ryšiu. Dažnai ping gali būti 350, 500 arba 1000 ms. Tada tampa beveik neįmanoma žaisti greitą šaudyklę su įvesties delsa.

Šios problemos sprendimas yra kliento pusės modeliavimo numatymas. Čia klientas pats taiko įvestį žaidėjo veikėjui, nelaukdamas atsakymo iš serverio. O kai gaunamas atsakymas, tiesiog palyginami rezultatai ir atnaujinamos oponentų pozicijos. Šiuo atveju uždelsimas nuo klavišo paspaudimo iki rezultato rodymo ekrane yra minimalus.

Čia svarbu suprasti niuansą: klientas visada piešia save pagal paskutinę įvestį, o priešai - su tinklo vėlavimu, pagal ankstesnę būseną iš serverio duomenų. Tai yra, šaudydamas į priešą, žaidėjas mato jį praeityje, palyginti su savimi. Daugiau apie klientų prognozes rašėme anksčiau.

Taigi kliento numatymas išsprendžia vieną problemą, bet sukuria kitą: jei žaidėjas šaudo į tašką, kuriame priešas buvo praeityje, serveryje šaudydamas tame pačiame taške, priešo toje vietoje gali nebebūti. Serverio vėlavimo kompensavimas bando išspręsti šią problemą. Kai šaudomas ginklas, serveris atkuria žaidimo būseną, kurią žaidėjas matė vietoje šūvio metu, ir patikrina, ar jis tikrai galėjo pataikyti į priešą. Jei atsakymas yra „taip“, smūgis skaičiuojamas, net jei priešo tuo metu nebėra serveryje.

Turėdami šias žinias, „Dino Squad“ pradėjome diegti serverio atsilikimo kompensavimą. Visų pirma, mes turėjome suprasti, kaip atkurti serveryje tai, ką klientas matė? O ką konkrečiai reikia restauruoti? Mūsų žaidime ginklų ir sugebėjimų smūgiai apskaičiuojami naudojant spindulių transliacijas ir perdangas – tai yra sąveikaujant su priešo fiziniais susidūrimo įrenginiais. Atitinkamai, mes turėjome atkurti šių kolaiderių padėtį, kurią žaidėjas „matė“ vietoje, serveryje. Tuo metu naudojome Unity 2018.x versiją. Fizikos API ten yra statinė, fizinis pasaulis egzistuoja vienoje kopijoje. Jokiu būdu negalima išsaugoti jo būsenos ir atkurti iš dėžutės. Taigi ką daryti?

Sprendimas buvo paviršiuje; visus jo elementus mes jau panaudojome kitoms problemoms spręsti:

  1. Kiekvienam klientui turime žinoti, kuriuo metu jis pamatė priešininkus, kai paspaudė klavišus. Šią informaciją jau įrašėme į įvesties paketą ir panaudojome koreguodami kliento prognozę.
  2. Turime turėti galimybę išsaugoti žaidimo būsenų istoriją. Būtent jame užimsime savo priešininkų (taigi ir jų susidūrėjų) pozicijas. Serveryje jau turėjome būsenos istoriją, ją panaudojome kurdami deltos. Žinodami tinkamą laiką, nesunkiai rastume tinkamą istorijos būseną.
  3. Dabar, kai turime žaidimo būseną iš istorijos, turime sugebėti sinchronizuoti žaidėjo duomenis su fizinio pasaulio būkle. Esami kolidoriai – perkelti, trūkstami – sukurti, nereikalingi – naikinti. Ši logika taip pat jau buvo parašyta ir susideda iš kelių ECS sistemų. Mes naudojome jį keliems žaidimų kambariams surengti viename Unity procese. Ir kadangi fizinis pasaulis yra vienas kiekvienam procesui, jis turėjo būti pakartotinai naudojamas tarp kambarių. Prieš kiekvieną modeliavimo žymėjimą mes „atstatėme“ fizinio pasaulio būseną ir iš naujo inicijavome ją dabartinio kambario duomenimis, stengdamiesi kuo daugiau panaudoti „Unity“ žaidimo objektus per protingą telkimo sistemą. Liko tik panaudoti tą pačią žaidimo būsenos logiką iš praeities.

Sujungę visus šiuos elementus, gavome „laiko mašiną“, kuri galėtų grąžinti fizinio pasaulio būseną į reikiamą akimirką. Kodas pasirodė paprastas:

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

Liko tik sugalvoti, kaip panaudoti šią mašiną, kad būtų galima lengvai kompensuoti kadrus ir sugebėjimus.

Paprasčiausiu atveju, kai mechanika remiasi vienu hitscan, atrodo, kad viskas aišku: prieš žaidėjui šaudant, jam reikia grąžinti fizinį pasaulį į norimą būseną, atlikti raycast, suskaičiuoti pataikymą ar nepataikymą ir grąžinti pasaulį į pradinę būseną.

Tačiau „Dino Squad“ tokių mechanikų yra labai mažai! Dauguma žaidimo ginklų sukuria sviedinius – ilgaamžes kulkas, kurios skrenda į kelias imitacines erkes (kai kuriais atvejais – keliasdešimt erkių). Ką su jais daryti, kokiu laiku jie turėtų skristi?

В senovinis straipsnis apie Half-Life tinklo steką, tą patį klausimą uždavė vaikinai iš Valve, o jų atsakymas buvo toks: sviedinio atsilikimo kompensavimas yra problemiškas, ir geriau to vengti.

Neturėjome šios galimybės: sviediniai ginklai buvo pagrindinė žaidimo dizaino savybė. Taigi turėjome ką nors sugalvoti. Po kai kurių minčių šturmo suformulavome du variantus, kurie atrodė veiksmingi:

1. Sviedinį pririšame prie jį sukūrusio žaidėjo laiko. Kiekvieną serverio modeliavimo žymėjimą, kiekvieną žaidėjo kulką grąžiname fizinį pasaulį į kliento būseną ir atliekame reikiamus skaičiavimus. Šis metodas leido paskirstyti serverio apkrovą ir nuspėti sviedinių skrydžio laiką. Nuspėjamumas mums buvo ypač svarbus, nes visus sviedinius, įskaitant priešo sviedinius, prognozuojame ant kliento.

Kaip patobulinome mobiliojo šaulio balistinių skaičiavimų mechaniką su tinklo delsos kompensavimo algoritmu
Paveikslėlyje žaidėjas, esantis ties 30, paleidžia raketą laukdamas: jis mato, kuria kryptimi bėga priešas, ir žino apytikslį raketos greitį. Vietoje jis mato, kad pataikė į taikinį ties 33 varnele. Dėl vėlavimo kompensacijos jis taip pat bus rodomas serveryje

2. Viską darome taip pat kaip ir pirmame variante, tačiau suskaičiavę vieną kulkos modeliavimo varnelę nesustojame, o toliau imituojame jos skrydį to paties serverio varnele, kiekvieną kartą priartindami jo laiką prie serverio po vieną pažymėkite ir atnaujinkite susidūrimo įtaiso pozicijas. Tai darome tol, kol įvyksta vienas iš dviejų dalykų:

  • Kulka pasibaigė. Tai reiškia, kad skaičiavimai baigti, galime skaičiuoti nepataikytą ar pataikymą. Ir tai yra ta pati varnelė, kurioje buvo paleistas šūvis! Mums tai buvo ir pliusas, ir minusas. Pliusas - nes šaudymo žaidėjui tai žymiai sumažino uždelsimą tarp smūgio ir priešo sveikatos pablogėjimo. Minusas tas, kad toks pat efektas buvo pastebėtas ir varžovams šaudant į žaidėją: priešas, atrodytų, tik iššovė lėtą raketą, o žala jau buvo suskaičiuota.
  • Kulka pasiekė serverio laiką. Tokiu atveju jo modeliavimas bus tęsiamas kito serverio žymėjimo metu be jokio vėlavimo kompensavimo. Lėtiesiems sviediniams tai teoriškai galėtų sumažinti fizikos atšaukimų skaičių, palyginti su pirmuoju variantu. Tuo pačiu metu padidėjo netolygi modeliavimo apkrova: serveris arba nedirbo, arba viename serverio varnele skaičiavo keliolika modeliavimo varnelių kelioms kulkoms.

Kaip patobulinome mobiliojo šaulio balistinių skaičiavimų mechaniką su tinklo delsos kompensavimo algoritmu
Tas pats scenarijus kaip ir ankstesniame paveikslėlyje, bet apskaičiuotas pagal antrą schemą. Raketa „pagavo“ serverio laiką tuo pačiu metu, kai įvyko šūvis, o pataikymas gali būti skaičiuojamas jau kitą kartą. Esant 31 varnelei, šiuo atveju vėlavimo kompensacija nebetaikoma

Mūsų įgyvendinime šie du metodai skyrėsi vos keliomis kodo eilutėmis, todėl sukūrėme abu ir ilgą laiką jie egzistavo lygiagrečiai. Priklausomai nuo ginklo mechanikos ir kulkos greičio, kiekvienam dinozaurui pasirinkome vieną ar kitą variantą. Posūkio taškas čia buvo pasirodymas mechanikos žaidime, pavyzdžiui, „jei tu tiek daug kartų smogsi priešui per tokį ir tokį laiką, gauk tokią ir tokią premiją“. Bet kuris mechanikas, kuriame žaidėjo smūgio į priešą laikas atliko svarbų vaidmenį, atsisakė dirbti su antruoju metodu. Taigi mes pasirinkome pirmąjį variantą, kuris dabar taikomas visiems ginklams ir visiems aktyviems žaidimo sugebėjimams.

Atskirai verta iškelti našumo klausimą. Jei manėte, kad visa tai pristabdys, atsakau: taip. „Unity“ gana lėtai judina greitintuvus ir juos įjungia bei išjungia. Dino būryje „blogiausiu atveju“ vienu metu kovoje gali būti keli šimtai sviedinių. Kolaiderių perkėlimas skaičiuojant kiekvieną sviedinį atskirai yra neįperkama prabanga. Todėl mums buvo absoliučiai būtina sumažinti fizikos „atšaukimų“ skaičių. Norėdami tai padaryti, ECS sukūrėme atskirą komponentą, kuriame įrašome žaidėjo laiką. Pridėjome jį prie visų objektų, kuriems reikalinga atsilikimo kompensacija (sviediniai, gebėjimai ir kt.). Prieš pradėdami apdoroti tokius objektus, iki to laiko juos sugrupuojame ir apdorojame kartu, grąžindami fizinį pasaulį kiekvienai klasteriui vieną kartą.

Šiame etape turime bendrai veikiančią sistemą. Jo kodas šiek tiek supaprastinta forma:

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

Liko tik sukonfigūruoti detales:

1. Supraskite, kiek apriboti maksimalų judėjimo atstumą laike.

Mums buvo svarbu, kad žaidimas būtų kuo prieinamesnis prastų mobiliųjų tinklų sąlygomis, todėl istoriją apribojome 30 varnelių (su 20 Hz dažniu). Tai leidžia žaidėjams smogti priešininkams net esant labai dideliems pingams.

2. Nustatykite, kurie objektai gali būti perkelti laiku, o kurie ne.

Mes, žinoma, išjudiname varžovus. Tačiau, pavyzdžiui, montuojami energijos skydai nėra. Nusprendėme, kad geriau pirmenybę teikti gynybai, kaip dažnai daroma internetinėse šaudyklėse. Jei žaidėjas jau įdėjo skydą dabartyje, praeities kulkos, kompensuojančios atsilikimą, neturėtų skristi pro jį.

3. Nuspręskite, ar reikia kompensuoti dinozaurų sugebėjimus: įkandimas, uodegos smūgis ir tt Nusprendėme, ko reikia, ir apdorojame juos pagal tas pačias taisykles kaip ir kulkas.

4. Nuspręskite, ką daryti su žaidėjo, kuriam atliekamas atsilikimo kompensavimas, susidūrimais. Gerąja prasme jų padėtis neturėtų pasislinkti į praeitį: žaidėjas turėtų matyti save tuo pačiu laiku, kuriuo dabar yra serveryje. Tačiau mes taip pat atšaukiame šaudymo žaidėjo susidūrimus, ir tam yra keletas priežasčių.

Pirma, tai pagerina klasterizavimą: galime naudoti tą pačią fizinę būseną visiems žaidėjams su artimais ping.

Antra, visuose spinduliuose ir persidengimuose mes visada neįtraukiame žaidėjo, kuriam priklauso sugebėjimai ar sviediniai, susidūrėjams. „Dino Squad“ žaidėjai valdo dinozaurus, kurių geometrija yra gana nestandartinė pagal šaulio standartus. Net jei žaidėjas šaudys neįprastu kampu, o kulkos trajektorija eina per žaidėjo dinozaurų susidūrimo įrenginį, kulka į tai nepaisys.

Trečia, mes apskaičiuojame dinozauro ginklo padėtis arba sugebėjimo pritaikymo tašką, naudodamiesi ECS duomenimis dar prieš prasidedant atsilikimo kompensavimui.

Dėl to mums nesvarbi tikroji atsilikimą kompensuojančio žaidėjo koliderių padėtis, todėl pasukome produktyvesniu ir tuo pačiu paprastesniu keliu.

Tinklo delsos negalima tiesiog pašalinti, ją galima tik užmaskuoti. Kaip ir bet kuris kitas maskavimo būdas, serverio vėlavimo kompensacija turi kompromisų. Tai pagerina žaidėjo, kuris šaudo, žaidimų patirtį to žaidėjo, į kurį šaudoma, sąskaita. Tačiau „Dino Squad“ pasirinkimas čia buvo akivaizdus.

Žinoma, už visa tai taip pat turėjo sumokėti išaugęs viso serverio kodo sudėtingumas – tiek programuotojams, tiek žaidimų dizaineriams. Jei anksčiau modeliavimas buvo paprastas nuoseklus sistemų iškvietimas, tai su vėlavimo kompensavimu jame atsirado įdėtos kilpos ir šakos. Taip pat įdėjome daug pastangų, kad būtų patogu dirbti.

2019 m. versijoje (ir galbūt šiek tiek anksčiau) „Unity“ pridėjo visišką nepriklausomų fizinių scenų palaikymą. Įdiegėme juos serveryje beveik iš karto po atnaujinimo, nes norėjome greitai atsikratyti fizinio pasaulio, bendro visoms patalpoms.

Kiekvienam žaidimų kambariui suteikėme savo fizinę sceną ir taip pašalinome poreikį „išvalyti“ sceną iš gretimo kambario duomenų prieš apskaičiuojant modeliavimą. Pirma, tai žymiai padidino produktyvumą. Antra, tai leido atsikratyti visos klasės klaidų, kurios atsirado, jei programuotojas padarė klaidą scenos valymo kode pridėdamas naujų žaidimo elementų. Tokias klaidas buvo sunku ištaisyti, todėl dažnai fizinių objektų būsena vienoje patalpoje „tekėjo“ į kitą kambarį.

Be to, atlikome keletą tyrimų, ar fizinės scenos gali būti naudojamos fizinio pasaulio istorijai saugoti. Tai yra sąlyginai kiekvienam kambariui paskirkite ne vieną sceną, o 30 scenų ir iš jų padarykite ciklinį buferį, kuriame saugosite istoriją. Apskritai variantas pasirodė veikiantis, tačiau jo neįgyvendinome: beprotiško produktyvumo augimo nerodė, o reikalavo gana rizikingų pakeitimų. Sunku buvo nuspėti, kaip elgsis serveris, kai ilgai dirbs tiek daug scenų. Todėl laikėmės taisyklės: „Jei jis nėra sugedęs, netaisykite jo".

Šaltinis: www.habr.com

Добавить комментарий