Желілік кідірістің орнын толтыру алгоритмі бар мобильді атқыш үшін баллистикалық есептеулер механикасын қалай жетілдірдік

Желілік кідірістің орнын толтыру алгоритмі бар мобильді атқыш үшін баллистикалық есептеулер механикасын қалай жетілдірдік

Сәлем, мен Никита Брижакпын, Pixonic компаниясының сервер әзірлеушісі. Бүгін мен мобильді мультипликатордағы артта қалуды өтеу туралы айтқым келеді.

Сервердің кешігуін өтеу туралы көптеген мақалалар жазылған, оның ішінде орыс тілінде. Бұл таңқаларлық емес, өйткені бұл технология 90-жылдардың аяғынан бастап көп ойыншы FPS құруда белсенді түрде қолданылып келеді. Мысалы, сіз QuakeWorld режимін есте сақтай аласыз, ол оны алғашқылардың бірі болды.

Біз оны мобильді көп ойыншы Dino Squad шутерінде де қолданамыз.

Бұл мақалада менің мақсатым - мың рет жазылған нәрсені қайталау емес, технология стек пен негізгі ойын мүмкіндіктерін ескере отырып, ойынымызда кешігуді өтеуді қалай жүзеге асырғанымызды айту.

Біздің кортекс пен технология туралы бірнеше сөз.

Dino Squad - желілік мобильді PvP шутері. Ойыншылар әртүрлі қарулармен жабдықталған динозаврларды басқарады және 6v6 командаларында бір-бірімен күреседі.

Клиент те, сервер де Unity негізінде құрылған. Архитектура атқыштар үшін өте классикалық: сервер авторитарлық, ал клиенттерді болжау клиенттерде жұмыс істейді. Ойын симуляциясы ішкі ECS көмегімен жазылған және серверде де, клиентте де қолданылады.

Егер сіз кешігуді өтеу туралы бірінші рет естіп жатсаңыз, міне осы мәселеге қысқаша экскурсия.

Көп ойыншы FPS ойындарында матч әдетте қашықтағы серверде имитацияланады. Ойыншылар серверге енгізген мәліметтерін (басылған пернелер туралы ақпаратты) жібереді және жауап ретінде сервер алынған деректерді ескере отырып, оларға жаңартылған ойын күйін жібереді. Бұл өзара әрекеттесу схемасымен алға пернені басу мен ойыншы кейіпкерінің экранда қозғалу сәті арасындағы кідіріс әрқашан пингтен үлкен болады.

Жергілікті желілерде бұл кідіріс (әдетте енгізу лагі деп аталады) байқалмауы мүмкін, интернет арқылы ойнаған кезде кейіпкерді басқару кезінде «мұзда сырғанау» сезімін тудырады. Бұл мәселе мобильді желілер үшін екі есе өзекті, мұнда ойнатқыштың пингі 200 мс болған жағдайда әлі де тамаша қосылым болып саналады. Көбінесе пинг 350, 500 немесе 1000 мс болуы мүмкін. Содан кейін кіріс лагымен жылдам шутер ойнау мүмкін емес.

Бұл мәселенің шешімі клиенттік модельдеуді болжау болып табылады. Мұнда клиент серверден жауап күтпей-ақ, ойыншы таңбасына енгізуді өзі қолданады. Жауап алынған кезде ол жай ғана нәтижелерді салыстырады және қарсыластардың позицияларын жаңартады. Бұл жағдайда пернені басу мен нәтижені экранда көрсету арасындағы кідіріс минималды.

Мұнда нюансты түсіну маңызды: клиент әрқашан өзінің соңғы кірісіне сәйкес өзін тартады, ал жаулары - серверден алынған деректердің алдыңғы күйіне сәйкес желілік кешігумен. Яғни, қарсыласқа оқ атқан кезде ойыншы оны өзіне қатысты өткенде көреді. Клиентті болжау туралы толығырақ біз бұрын жазғанбыз.

Осылайша, клиентті болжау бір мәселені шешеді, бірақ екіншісін жасайды: егер ойыншы қарсыласы бұрын болған нүктеде, сол нүктеде ату кезінде серверде атса, қарсылас енді ол жерде болмауы мүмкін. Сервердің кешігуінің орнын толтыру бұл мәселені шешуге тырысады. Қару атылған кезде сервер ойыншының атыс кезінде жергілікті жерде көрген ойын күйін қалпына келтіреді және оның шынымен жауды соғуы мүмкін бе екенін тексереді. Жауап «иә» болса, қарсылас сол кезде серверде болмаса да, соққы есептеледі.

Осы біліммен қаруланған біз Dino Squad-та серверлік кешігуді өтеуді жүзеге асыра бастадық. Ең алдымен, біз клиент көрген нәрсені серверде қалай қалпына келтіру керектігін түсінуіміз керек еді? Ал нақты нені қалпына келтіру керек? Біздің ойында қару-жарақ пен қабілеттердің соққылары сәулелер мен қабаттасулар арқылы есептеледі, яғни қарсыластың физикалық коллайдерлерімен өзара әрекеттесу арқылы. Тиісінше, біз серверде ойыншы жергілікті «көрген» осы коллайдерлердің орнын қайта шығаруымыз керек болды. Ол кезде біз Unity 2018.x нұсқасын пайдаландық. Физика API статикалық, физикалық әлем бір көшірмеде бар. Оның күйін сақтап, содан кейін оны қораптан қалпына келтіруге мүмкіндік жоқ. Сонымен не істеу керек?

Шешім бетінде болды; оның барлық элементтерін біз басқа мәселелерді шешу үшін пайдаландық:

  1. Әрбір клиент үшін біз пернелерді басқан кезде қарсыластарды қай уақытта көргенін білуіміз керек. Біз бұл ақпаратты кіріс бумасына жазып қойдық және оны клиент болжамын реттеу үшін пайдаландық.
  2. Біз ойын күйлерінің тарихын сақтай білуіміз керек. Онда біз қарсыластарымыздың (демек, олардың коллайдерлерінің) позицияларын ұстанатын боламыз. Бізде серверде күй тарихы болды, оны құру үшін пайдаландық дельталар. Тиісті уақытты біле отырып, біз тарихта дұрыс күйді оңай таба аламыз.
  3. Енді бізде тарихтағы ойын күйі болғандықтан, біз ойыншы деректерін физикалық әлемнің күйімен синхрондауға қабілетті болуымыз керек. Қолданыстағы коллайдерлер – қозғалады, жетіспейтіндері – жасайды, қажетсіздері – жойылады. Бұл логика да бұрыннан жазылған және бірнеше ECS жүйесінен тұрды. Біз оны бір Unity процесінде бірнеше ойын бөлмелерін өткізу үшін пайдаландық. Физикалық әлем әр процесте бір болғандықтан, оны бөлмелер арасында қайта пайдалану керек болды. Модельдеудің әрбір белгісінің алдында біз физикалық әлемнің күйін «қалпына келтіреміз» және оны ағымдағы бөлмеге арналған деректермен қайта инициализациялаймыз, ақылды біріктіру жүйесі арқылы Unity ойын нысандарын мүмкіндігінше қайта пайдалануға тырысамыз. Ойын күйі үшін бұрынғы логиканы шақыру ғана қалды.

Осы элементтердің барлығын біріктіру арқылы біз физикалық әлемнің күйін қажетті сәтке қайтара алатын «уақыт машинасын» алдық. Код қарапайым болып шықты:

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

Бұл машинаны кадрлар мен қабілеттердің орнын толтыру үшін қалай пайдалану керектігін анықтау ғана қалды.

Ең қарапайым жағдайда, механика бір соққыға негізделгенде, бәрі түсінікті болып көрінеді: ойыншы түсіру алдында физикалық әлемді қажетті күйге қайтару керек, рейкаст жасау, соққыны немесе жіберіп алуды санау және әлемді бастапқы күйіне қайтару.

Бірақ Dino Squad-да мұндай механиктер өте аз! Ойындағы қарулардың көпшілігі снарядтарды жасайды - бірнеше модельдеу кенелері үшін ұшатын ұзақ мерзімді оқтар (кейбір жағдайларда ондаған кенелер). Олармен не істеу керек, олар қай уақытта ұшуы керек?

В көне мақала Half-Life желісінің стекі туралы Valve жігіттері бірдей сұрақты қойды және олардың жауабы келесідей болды: снарядтардың кешігуін өтеу проблемалы және одан аулақ болған дұрыс.

Бізде мұндай опция болған жоқ: снарядтарға негізделген қарулар ойын дизайнының негізгі ерекшелігі болды. Сондықтан бізге бірдеңе ойлап табу керек болды. Миға шабуылдан кейін біз жұмыс істеген екі нұсқаны тұжырымдадық:

1. Снарядты оны жасаған ойыншының уақытына байланыстырамыз. Сервер симуляциясының әрбір белгісі, әрбір ойыншының әрбір оқтары үшін біз физикалық әлемді клиент күйіне қайтарамыз және қажетті есептеулерді орындаймыз. Бұл тәсіл серверге бөлінген жүктемені және снарядтардың болжамды ұшу уақытын алуға мүмкіндік берді. Болжамдық біз үшін әсіресе маңызды болды, өйткені бізде клиентте болжаған барлық снарядтар, соның ішінде жау снарядтары бар.

Желілік кідірістің орнын толтыру алгоритмі бар мобильді атқыш үшін баллистикалық есептеулер механикасын қалай жетілдірдік
Суретте 30-шы белгідегі ойыншы зымыранды күтумен атқылайды: ол жаудың қай бағытта жүгіріп келе жатқанын көреді және зымыранның шамамен жылдамдығын біледі. Жергілікті жерде ол 33-ші кенеде нысанаға тигенін көреді. Кешігуді өтеудің арқасында ол серверде де пайда болады

2. Біз бәрін бірінші нұсқадағыдай жасаймыз, бірақ оқ имитациясының бір белгісін санап, біз тоқтамаймыз, бірақ оның уақытын серверге жақындата отырып, сол сервер белгісінің ішінде имитациялауды жалғастырамыз. бір-бірден белгілеу және коллайдер позицияларын жаңарту. Біз мұны екі нәрсенің бірі болғанша істейміз:

  • Оқ мерзімі бітті. Бұл есептеулер аяқталды дегенді білдіреді, біз жіберіп алған немесе соққыны санай аламыз. Бұл оқ атылған кенеде! Біз үшін бұл плюс пен минус болды. Плюс - атыс ойыншысы үшін бұл соққы арасындағы кідіріс пен қарсыластың денсаулығының төмендеуін айтарлықтай азайтты. Кемшілігі сол, қарсыластар ойыншыға оқ жаудырған кезде де осындай әсер байқалды: жау тек баяу зымыранмен атқылаған сияқты, ал залал есептеліп қойған.
  • Маркер сервер уақытына жетті. Бұл жағдайда оны модельдеу кез келген кешігуді өтеусіз келесі сервер белгісінде жалғасады. Баяу снарядтар үшін бұл бірінші нұсқамен салыстырғанда физикалық кері қайтарулар санын теориялық тұрғыдан азайтуы мүмкін. Сонымен қатар, модельдеуге біркелкі емес жүктеме өсті: сервер не бос тұрды, немесе бір сервер белгішесінде бірнеше таңбалауыштар үшін ондаған симуляциялық белгілерді есептеді.

Желілік кідірістің орнын толтыру алгоритмі бар мобильді атқыш үшін баллистикалық есептеулер механикасын қалай жетілдірдік
Алдыңғы суреттегідей сценарий, бірақ екінші схемаға сәйкес есептелген. Зымыран ату орын алған белгіде сервер уақытына «қуып жетті» және соққыны келесі белгіде ерте санауға болады. 31-ші белгіде бұл жағдайда кешіктірілген өтемақы енді қолданылмайды

Біздің енгізуімізде бұл екі тәсіл кодтың бірнеше жолында ғана ерекшеленді, сондықтан біз екеуін де жасадық және ұзақ уақыт бойы олар параллель болды. Қарудың механикасына және оқтың жылдамдығына байланысты біз әрбір динозавр үшін бір немесе басқа нұсқаны таңдадық. Мұндағы бетбұрыс механика ойынындағы «жауды мынау-мұнша уақытта сонша рет ұрсаң, анау-мынау бонус ал» деген сияқты пайда болды. Ойыншының жауға тиген уақыты маңызды рөл атқарған кез келген механик екінші тәсілмен жұмыс істеуден бас тартты. Осылайша, біз бірінші нұсқаны қолдандық және ол енді барлық қарулар мен ойындағы барлық белсенді қабілеттерге қатысты.

Өнімділік мәселесін бөлек көтерген жөн. Егер сіз мұның барлығын баяулатады деп ойласаңыз, мен жауап беремін: солай. Бірлік коллайдерлерді жылжытуда және оларды қосу және өшіруде өте баяу. Дино отрядында, «ең нашар» жағдайда, шайқаста бір уақытта бірнеше жүздеген снарядтар болуы мүмкін. Әрбір снарядты жеке-жеке санау үшін коллайдерлерді жылжыту - бұл қол жетпес сән-салтанат. Сондықтан бізге физикадағы «қайтарулардың» санын азайту өте қажет болды. Ол үшін біз ECS-де ойнатқыштың уақытын жазатын жеке құрамдас құрдық. Біз оны кешігуді өтеуді талап ететін барлық нысандарға қостық (снарядтар, қабілеттер және т.б.). Мұндай нысандарды өңдеуді бастамас бұрын, біз оларды осы уақытқа дейін кластерлейміз және әрбір кластер үшін физикалық әлемді бір рет кері айналдыра отырып, бірге өңдейміз.

Бұл кезеңде бізде жалпы жұмыс істейтін жүйе бар. Оның коды біршама жеңілдетілген түрде:

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

Мәліметтерді конфигурациялау ғана қалды:

1. Қозғалыстың максималды қашықтығын уақыт бойынша қаншалықты шектеу керектігін түсіну.

Нашар мобильді желілер жағдайында ойынды мүмкіндігінше қолжетімді ету біз үшін маңызды болды, сондықтан біз оқиғаны 30 белгімен (20 Гц жиілікпен) шектедік. Бұл ойыншыларға тіпті өте жоғары пингтерде де қарсыластарын ұруға мүмкіндік береді.

2. Қандай заттарды уақытында жылжытуға болатынын, қайсысын жылжытуға болмайтынын анықтаңыз.

Біз, әрине, қарсыластарымызды жылжытамыз. Бірақ орнатуға болатын қуат қалқандары, мысалы, жоқ. Біз көбінесе желідегі атқыштардағыдай қорғаныс қабілетіне басымдық берген дұрыс деп шештік. Егер ойыншы қалқанды қазірдің өзінде орналастырып қойған болса, өткендегі лаг-компенсацияланған оқтар ол арқылы ұшып кетпеуі керек.

3. Динозаврлардың қабілеттерін өтеу қажет пе, жоқ па, соны шешіңіз: тістеу, құйрықты соғу және т.б. Біз не қажет екенін шештік және оларды оқпен бірдей ережелерге сәйкес өңдедік.

4. Кешігу орнын толтыру орындалатын ойыншының коллайдерлерімен не істеу керектігін анықтаңыз. Жақсы мағынада, олардың позициясы өткенге ауыспауы керек: ойыншы өзін қазір серверде болған уақытта көруі керек. Дегенмен, біз ату ойыншысының коллайдерлерін де кері айналдырамыз және мұның бірнеше себептері бар.

Біріншіден, бұл кластерлеуді жақсартады: біз жақын пингтері бар барлық ойыншылар үшін бірдей физикалық күйді пайдалана аламыз.

Екіншіден, барлық сәулелер мен қабаттасуларда біз әрқашан қабілеттері немесе снарядтары бар ойыншының коллайдерлерін жоққа шығарамыз. Dino Squad ойынында ойыншылар ату стандарттары бойынша стандартты емес геометрияға ие динозаврларды басқарады. Ойыншы әдеттен тыс бұрышта атса және оқтың траекториясы ойыншының динозавр коллайдерінен өтіп кетсе де, оқ оны елемейді.

Үшіншіден, біз динозавр қаруының позицияларын немесе ECS деректерін пайдалана отырып, кідірістің орнын толтыру басталғанға дейін қабілет қолдану нүктесін есептейміз.

Нәтижесінде, лаг-компенсацияланған ойыншының коллайдерлерінің нақты позициясы біз үшін маңызды емес, сондықтан біз өнімдірек және сонымен бірге қарапайым жолды ұстандық.

Желінің кідірісін жай ғана жою мүмкін емес, оны тек жасыруға болады. Кез келген басқа бетперделеу әдісі сияқты, сервердің кешігуінің өтемақысының да өз мәмілелері бар. Ол оқ атқан ойыншының есебінен атылатын ойыншының ойын тәжірибесін жақсартады. Алайда Dino Squad үшін мұнда таңдау анық болды.

Әрине, мұның бәрін бағдарламашылар үшін де, ойын дизайнерлері үшін де тұтастай серверлік кодтың күрделілігінің жоғарылауымен төлеуге тура келді. Егер бұрын модельдеу жүйелердің қарапайым дәйекті шақыруы болса, онда кешіктірілген компенсациямен онда кірістірілген циклдар мен тармақтар пайда болды. Біз онымен жұмыс істеуге ыңғайлы болу үшін де көп күш жұмсадық.

2019 нұсқасында (және сәл ертерек) Unity тәуелсіз физикалық көріністерге толық қолдауды қосты. Біз оларды серверге жаңартудан кейін бірден енгіздік, өйткені біз барлық бөлмелерге ортақ физикалық әлемнен тез арылғымыз келді.

Біз әрбір ойын бөлмесіне өзінің физикалық көрінісін бердік және осылайша симуляцияны есептеу алдында көрші бөлменің деректерінен көріністі «тазарту» қажеттілігін жойдық. Біріншіден, бұл өнімділіктің айтарлықтай өсуіне мүмкіндік берді. Екіншіден, егер бағдарламашы жаңа ойын элементтерін қосу кезінде сахнаны тазалау кодында қате жіберсе, пайда болған қателердің бүкіл класынан құтылуға мүмкіндік берді. Мұндай қателерді түзету қиын болды және олар көбінесе бір бөлменің көрінісіндегі физикалық нысандардың басқа бөлмеге «ағып кетуіне» әкелді.

Сонымен қатар, біз физикалық көріністерді физикалық әлемнің тарихын сақтау үшін пайдалануға болатынын зерттедік. Яғни, шартты түрде әр бөлмеге бір көріністі емес, 30 көріністі бөліп, олардан оқиғаны сақтайтын циклдік буфер жасаңыз. Тұтастай алғанда, опция жұмыс істеп тұрды, бірақ біз оны жүзеге асырмадық: ол өнімділіктің ешқандай ессіз өсуін көрсетпеді, бірақ өте қауіпті өзгерістерді қажет етті. Көптеген көріністермен ұзақ уақыт жұмыс істегенде сервердің қалай әрекет ететінін болжау қиын болды. Сондықтан біз ережені ұстандық: «Егер ол бұзылмаса, оны түзетпеңіз«.

Ақпарат көзі: www.habr.com

пікір қалдыру