Як мы абважылі механіку балістычнага разліку для мабільнага шутэра алгарытмам кампенсавання сеткавай затрымкі

Як мы абважылі механіку балістычнага разліку для мабільнага шутэра алгарытмам кампенсавання сеткавай затрымкі

Прывітанне, я Мікіта Брыжак, серверны распрацоўшчык з Pixonic. Сёння я хацеў бы пагаварыць аб кампенсацыі лагаў у мабільным мультыплэеры.

Пра серверную лагкампенсацыю напісана шмат артыкулаў, у тым ліку на рускай мове. У гэтым няма нічога дзіўнага, бо гэтая тэхналогія актыўна выкарыстоўваецца пры стварэнні шматкарыстальніцкіх FPS яшчэ з канца 90-ых. Напрыклад, можна ўспомніць мод QuakeWorld, які звярнуўся да яе адным з першых.

Выкарыстоўваны яе і мы ў сваім мабільным мультыплэерным шутэры Dino Squad.

У гэтым артыкуле мая мэта ― не паўтарыць тое, што было напісана ўжо тысячу разоў, але расказаць, як мы ўкаранялі лагкампенсацыю ў нашу гульню з улікам нашага тэхналагічнага стэка і асаблівасцяў кар-геймплэя.

У пары слоў аб нашым кары і тэхналогіях.

Dino Squad ― сеткавы мабільны PvP-шутэр. Гульцы кіруюць дыназаўрамі, абчэпленымі разнастайным узбраеннем, і ваююць сябар з сябрам камандамі 6 на 6.

І кліент, і сервер у нас на Unity. Архітэктура даволі класічная для шутэраў: сервер - аўтарытарны, а на кліентах працуе кліенцкае прадказанне. Гульнявая сімуляцыя напісана з выкарыстаннем in-house ECS і выкарыстоўваецца як на сэрвэры, так і на кліенце.

Калі вы ўпершыню пачулі пра лагакампенсацыю, вось кароткі экскурс у праблематыку.

У шматкарыстальніцкіх FPS-гульнях матч, як правіла, сімулюецца на выдаленым серверы. Гульцы адпраўляюць на сервер свой інпут (інфармацыю аб націснутых клавішах), а ў адказ сервер дасылае ім абноўлены стан гульні з улікам атрыманых дадзеных. Пры такой схеме ўзаемадзеяння затрымка паміж націскам на клавішу "наперад" і тым момантам, калі персанаж гульца на экране ссунецца з месца, заўсёды будзе больш пінгу.

Калі на лакальных сетках гэтая затрымка (у народзе названая input lag) можа быць незаўважная, то пры гульні праз інтэрнэт яна стварае адчуванне "слізгацення па лёдзе" пры кіраванні персанажам. Гэтая праблема ўдвая актуальная для мабільных сетак, дзе выпадак, калі ў гульца пінг складае 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-ым ціцы ў дадзеным выпадку лагкампенсацыя ўжо не ўжываецца.

У нашай рэалізацыі гэтыя два падыходы адрозніваліся літаральна парай радкоў кода, таму запілавалі мы абодва, і доўгі час яны ў нас існавалі паралельна. У залежнасці ад механікі зброі і хуткасці палёту кулі мы выбіралі той ці іншы варыянт для кожнага дыназаўра. Пераломным момантам тут стала з'яўленне ў гульні механік тыпу "калі ты трапіў столькі разоў па ворагу за такі час, атрымай нейкі бонус". Любая механіка, дзе час, у які гулец патрапіў па ворагу, меў важную ролю, адмаўлялася сябраваць з другім падыходам. Таму ў выніку мы спыніліся на першым варыянце, і цяпер ён ужываецца для ўсёй зброі і ўсіх актыўных здольнасцяў у гульні.

Асобна варта ўзняць пытанне прадукцыйнасці. Калі вам падалося, што ўсё гэта будзе тармазіць, адказваю: так яно і ёсць. Перасоўванне калайдэраў, іх уключэнне і выключэнне Unity робіць даволі павольна. У Dino Squad у «горшым» выпадку ў баі можа адначасова існаваць некалькі сотняў проджэктайлаў. Рухаць калайдэры, каб палічыць кожны проджэктайлаў паасобку, ― недазваляльная раскоша. Таму нам было зусім неабходна мінімізаваць колькасць "адкатаў" фізікі. Для гэтага мы стварылі асобны кампанент у 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 сцэн, і зрабіць з іх цыклічны буфер, у якім і захоўваць гісторыю. У цэлым варыянт апынуўся працоўным, але ўкараняць мы яго не сталі: ён не паказаў нейкага вар'ята прыросту прадукцыйнасці, але патрабаваў даволі рызыкоўных змен. Складана было прадказаць, як павядзе сябе сервер пры працяглай працы з такой колькасцю сцэн. Таму мы рушылі ўслед правілу: «Калі ён не зламаўся, ня выправіць.

Крыніца: habr.com

Дадаць каментар