Як ми обвісили механіку балістичного розрахунку для мобільного шутера алгоритмом компенсації затримки мережі

Як ми обвісили механіку балістичного розрахунку для мобільного шутера алгоритмом компенсації затримки мережі

Привіт, я Микита Брижак, серверний розробник із 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

Додати коментар або відгук