Про мережеву модель в іграх для початківців

Про мережеву модель в іграх для початківців
Останні два тижні я працював над мережевим двигуном для своєї гри. До цього я взагалі нічого не знав про мережеві технології в іграх, тому прочитав безліч статей і провів безліч експериментів, щоб усвідомити всі концепції і мати можливість написати власний мережевий движок.

У цьому посібнику я хотів би поділитися з вами різними концепціями, які вам потрібно вивчити перед написанням власного ігрового движка, а також найкращими ресурсами та статтями для їх вивчення.

Загалом існує два основних типи мережевих архітектур: peer-to-peer та клієнт-серверна. В архітектурі peer-to-peer (p2p) дані передаються між будь-якими парами підключених гравців, а в клієнт-серверній архітектурі дані передаються лише між гравцями та сервером.

Хоча архітектура peer-to-peer, як і раніше, використовується в деяких іграх, стандартом є клієнт-серверна: вона простіше в реалізації, вимагає канал меншої ширини та полегшує захист від читерства. Тому в цьому посібнику ми зосередимося на клієнт-серверній архітектурі.

Зокрема, нас найбільше цікавлять авторитарні сервери: у таких системах сервер завжди має рацію. Наприклад, якщо гравець думає, що знаходиться в координатах (10, 5), а сервер каже йому, що він (5, 3), то клієнт повинен замінити свою позицію тій, яку передає сервер, а не навпаки. Використання авторитарних серверів спрощує розпізнавання чітерів.

В ігрових мережних системах є три основні компоненти:

  • Транспортний протокол: як передаються дані між клієнтами та сервером.
  • Протокол програми: що передається від клієнтів серверу та від сервера клієнтам та у якому форматі.
  • Логіка програми: як дані, що передаються, використовуються для оновлення стану клієнтів і сервера.

Дуже важливо зрозуміти роль кожної частини та пов'язані з ними труднощі.

Транспортний протокол

Перший крок полягає у виборі протоколу для транспортування даних між сервером та клієнтами. Для цього існує два Інтернет-протоколи: TCP и UDP. Але ви можете створити власний транспортний протокол на основі одного з них або застосувати бібліотеку, в якій вони використовуються.

Порівняння TCP та UDP

І TCP, і UDP засновані на IP. IP дозволяє передавати пакет від джерела одержувачу, але не дає гарантій, що відправлений пакет рано чи пізно потрапить до одержувача, що він дістанеться хоча б раз і що послідовність пакетів прийде в правильному порядку. Більш того, пакет може містити лише обмежений розмір даних, що задається величиною MTU.

UDP є лише тонким шаром поверх IP. Отже, він має самі обмеження. На відміну від нього, TCP має безліч особливостей. Він забезпечує надійне впорядковане з'єднання між двома вузлами із перевіркою на помилки. Отже, TCP дуже зручний і використовується в багатьох інших протоколів, наприклад, в HTTP, Ftp и SMTP. Але всі ці функції мають свою ціну: затримку.

Щоб зрозуміти, чому ці функції можуть викликати затримку, слід розібратися, як працює TCP. Коли вузол-відправник передає пакет одержувачу, він очікує отримати підтвердження (ACK). Якщо через певний час він не отримує його (бо пакет або підтвердження було втрачено, або з якихось інших причин), то відправляє пакет повторно. Більш того, TCP гарантує отримання пакетів у правильному порядку, тому поки втрачений пакет не отримано, решта пакетів не можуть бути оброблені, навіть якщо вони вже отримані вузлом-отримувачем.

Але як ви напевно розумієте, затримка в розрахованих на багато користувачів іграх дуже важлива, особливо в таких активних жанрах, як FPS. Саме тому багато ігор використовують UDP із власним протоколом.

Власний протокол на основі UDP може бути ефективнішим за TCP з різних причин. Наприклад, він може позначати деякі пакети як надійні, інші — як ненадійні. Тому його не хвилює, чи дістався ненадійний пакет до отримувача. Або він може обробляти кілька потоків даних, щоб втрачений в одному потоці пакет не сповільнював інші потоки. Наприклад, може бути потік для введення гравця і ще один потік для повідомлень чату. Якщо повідомлення чату, яке не є терміновими даними, втрачено, воно не уповільнить спрацювання введення, яке є невідкладним. Або ж власний протокол може реалізувати надійність інакше, ніж у TCP, щоб бути ефективнішим за умов відеоігор.

Отже, якщо TCP такий відстійний, то ми створюватимемо свій транспортний протокол на основі UDP?

Все трохи складніше. Навіть хоча TCP майже субоптимальний для ігрових мережевих систем, він може цілком добре працювати конкретно у вашій грі та заощадити ваш дорогоцінний час. Наприклад, затримка може і не бути проблемою для покрокової гри або гри, в яку можна грати тільки в мережах LAN, де затримки та втрата пакетів набагато менші, ніж в Інтернеті.

Багато успішних іграх, зокрема World of Warcraft, Minecraft і Terraria, використовується TCP. Однак у більшості FPS застосовуються власні протоколи на основі UDP, тому нижче ми поговоримо про них детальніше.

Якщо ви вирішите використовувати TCP, то переконайтеся, що вимкнено алгоритм Нейгла, тому що він буферизує пакети перед відправкою, а значить, збільшує затримку.

Щоб докладніше дізнатися про відмінності між UDP і TCP в контексті розрахованих на багато користувачів ігор, можна прочитати статтю Гленна Фідлера UDP vs. TCP.

Власний протокол

Отже, ви хочете створити власний транспортний протокол, але не знаєте з чого почати? Вам пощастило, адже Гленн Фідлер написав про це дві чудові статті. У них ви знайдете багато розумних думок.

Перша стаття Networking for Game Programmers 2008 року, простіше, ніж друга, Building A Game Network Protocol 2016 року. Рекомендую вам почати з старішої.

Зауважте, що Гленн Фідлер — великий прихильник використання власного протоколу на основі UDP. І після прочитання його статей ви, напевно, переймете у нього думку про те, що TCP має у відеоіграх серйозні недоліки, і захочете реалізувати власний протокол.

Але якщо ви новачок у роботі з мережами, то зробіть собі ласку та використовуйте TCP або бібліотеку. Для успішної реалізації власного транспортного протоколу необхідно попередньо багато чого навчитися.

Мережеві бібліотеки

Якщо вам потрібно щось ефективніше, ніж TCP, але ви не хочете морочитися реалізацією власного протоколу і вдаватися до безлічі подробиць, то можете скористатися мережевою бібліотекою. Їх дуже багато:

  • yojimbo Глена Фідлера
  • RakNet, яка більше не підтримується, але її форк SLikeNet схоже ще активний.
  • ENet — це бібліотека, створена для розрахованого на багато користувачів FPS Куб
  • Ігрові мережеві сокети компанії Valve

Я не пробував їх усі, але перевагу віддаю ENet, тому що вона проста у використанні та надійна. Крім того, вона має зрозумілу документацію і туторіал для початківців.

Транспортний протокол: висновок

Підіб'ємо підсумок: існує два основних транспортних протоколи: TCP і UDP. TCP має безліч корисних особливостей: надійність, збереження порядку пакетів, виявлення помилок. У UDP всього цього немає, зате TCP за своєю природою має підвищені затримки, неприпустимі для деяких ігор. Тобто для забезпечення низьких затримок можна створити власний протокол на основі UDP або використовувати бібліотеку, що реалізує транспортний протокол на UDP і адаптовану для відеоігор.

Вибір між TCP, UDP та бібліотекою залежить від кількох факторів. По-перше, від потреби гри: чи потрібні їй низькі затримки? По-друге, від вимог протоколу додатка: чи потрібний йому надійний протокол? Як ми побачимо з наступної частини, можна створити протокол програми, для якої цілком підійде ненадійний протокол. Нарешті, потрібно ще враховувати дослідність розробника мережевого двигуна.

У мене є дві поради:

  • Максимально абстрагуйте транспортний протокол від решти програми, щоб його можна було легко замінити, не переписуючи весь код.
  • Не займайтеся передчасною оптимізацією. Якщо ви не спеціаліст з мереж і не впевнені, чи потрібен вам власний транспортний протокол на основі UDP, то можете почати з TCP або бібліотеки, що забезпечують надійність, а потім протестувати та виміряти продуктивність. Якщо виникають проблеми і ви впевнені, що причина полягає в транспортному протоколі, то, можливо, настав час створювати власний транспортний протокол.

На завершення цієї частини рекомендую вам прочитати Introduction to Multiplayer Game Programming Браяна Хука, в якому розглянуто безліч тем, що тут обговорюються.

Протокол програми

Тепер, коли ми можемо обмінюватися даними між клієнтами та сервером, потрібно вирішити, які саме дані передавати та у якому форматі.

Класична схема у тому, що клієнти відправляють серверу введення чи дії, а сервер відправляє клієнтам поточний ігровий стан.

Сервер відправляє не повний, а відфільтрований стан із сутностями, які знаходяться поруч із гравцем. Він робить це з трьох причин. По-перше, повний стан може бути надто великим для передачі з високою частотою. По-друге, клієнтів здебільшого цікавлять візуальні та аудіодані, тому що більша частина ігрової логіки симулюється на сервері гри. По-третє, у деяких іграх гравець не повинен знати певних даних, наприклад, позицію противника на іншому кінці карти, адже в іншому випадку він може зняти пакети і точно знати, куди рухатися, щоб його вбити.

Серіалізація

Першим кроком буде перетворення даних, які ми хочемо відправити (введення або ігровий стан) у відповідний для передачі формат. Цей процес називається серіалізацією.

В голову відразу спадає на думку використовувати людиночитаний формат, наприклад JSON або XML. Але це буде абсолютно неефективно і марно займе більшу частину каналу.

Натомість рекомендується використовувати двійковий формат, який набагато компактніший. Тобто пакети матимуть лише кілька байтів. Тут потрібно враховувати проблему порядку байтів, який може відрізнятися на різних комп'ютерах.

Для серіалізації даних можна використовувати бібліотеку, наприклад:

Тільки переконайтеся, що бібліотека створює архіви, що портуються, і піклується про порядок байтів.

Альтернативним рішенням може бути самостійна реалізація, вона не є особливо складною, особливо якщо в коді ви використовуєте орієнтований на підхід. Крім того, вона дозволить вам виконувати оптимізацію, яка не завжди можлива при використанні бібліотеки.

Гленн Фідлер написав про серіалізацію дві статті: Reading and Writing Packets и Serialization Strategies.

стиснення

Кількість даних, що передаються між клієнтами та сервером, обмежена пропускною здатністю каналу. Стиснення даних дозволить передавати в кожному снепшоті більше даних, збільшити частоту оновлення або просто зменшити вимоги до каналу.

Бітова упаковка

Перша техніка – це бітова упаковка. Вона полягає у використанні рівно тієї кількості бітів, яка потрібна для опису потрібної величини. Наприклад, якщо у вас є перелік, який може мати 16 різних значень, то замість цілого байта (8 біт) можна використовувати лише 4 біти.

Гленн Фідлер пояснює, як реалізувати це, у другій частині статті Reading and Writing Packets.

Бітова упаковка особливо добре працює із дискретизацією, яка буде темою наступного розділу.

дискретизація

дискретизація - це техніка стиснення з втратами, яка полягає у використанні для кодування величини лише підмножини можливих значень. Найпростіше реалізувати дискретизацію округленням чисел із плаваючою комою.

Гленн Фідлер (знов!) показує, як застосовувати дискретизацію на практиці, у своїй статті Snapshot Compression.

Алгоритми стиснення

Наступною технікою будуть алгоритми стиску без втрат.

Ось, на мій погляд, три найцікавіші алгоритми, які потрібно знати:

  • Кодування Хаффмана із заздалегідь обчисленим кодом, який надзвичайно швидко і може давати хороші результати. Воно використовувалося для стиснення пакетів у мережевому двигуні Quake3.
  • zlib - Алгоритм стиснення загального призначення, який ніколи не збільшує обсяг даних. Як можна побачити тут, він застосовувався в багатьох областях застосування. Для відновлення станів він може бути надлишковим. Але він може і стати в нагоді, якщо вам потрібно відправляти клієнтам з сервера ассети, довгі тексти або рельєф.
  • Копіювання довжин серій — це, напевно, найпростіший алгоритм стиснення, але дуже ефективний для певних типів даних, і може використовуватися як етап попередньої обробки перед zlib. Він особливо підходить для стиснення рельєфу, що складається з тайлів або вокселів, у яких повторюється безліч сусідніх елементів.

Дельта-стиск

Остання методика стиснення – це дельта-стиск. Вона полягає в тому, що передаються лише відмінності між поточним ігровим станом та останнім станом, отриманим клієнтом.

Вперше вона була використана в мережевому двигуні Quake3. Ось дві статті, які пояснюють спосіб її використання:

Гленн Фідлер також використав її у другій частині своєї статті Snapshot Compression.

Шифрование

Крім того, вам може знадобитися шифрувати передачу інформації між клієнтами та сервером. На це є кілька причин:

  • приватність/конфіденційність: повідомлення можуть бути прочитані тільки одержувачем, і жодній іншій особі, яка виконує сніфінг мережі, не вдасться їх прочитати.
  • автентифікація: людина, яка бажає виконувати роль гравця, повинна знати її ключ.
  • запобігання читерству: зловмисним гравцям буде набагато складніше створювати власні пакети для читерства, їм доведеться відтворювати схему шифрування та знаходити ключ (який змінюється при кожному з'єднанні).

Я наполегливо рекомендую використати для цього бібліотеку. Пропоную користуватися лібонатріютому, що вона особливо проста і має відмінні туторіали. Особливо цікавий туторіал по обміну ключами, що дозволяє генерувати нові ключі під час кожного нового з'єднання.

Протокол програми: висновок

На цьому ми закінчимо протокол додатку. Я вважаю, що стиск зовсім необов'язковий і рішення про його використання залежить тільки від гри та необхідної пропускної спроможності каналу. Шифрування, на мою думку, обов'язково, але в першому прототипі можна обійтися без нього.

Логіка програми

Тепер ми здатні оновлювати стан клієнта, але можемо зіткнутися з проблемами затримок. Гравцю, виконавши введення, потрібно чекати на оновлення стану гри від сервера, щоб побачити, який вплив він зробив на світ.

Понад те, між двома оновленнями стану світ цілком статичний. Якщо частота оновлення станів низька, рухи будуть дуже смиканими.

Існує кілька технік, що дозволяють знизити вплив цієї проблеми, і в наступному розділі я розповім про них.

Техніки згладжування затримок

Усі описані у цьому розділі техніки детально розглянуті у серії Fast-Paced Multiplayer Габріеля Гамбетти. Я наполегливо рекомендую прочитати цю чудову серію статей. У ній також є інтерактивне демо, що дозволяє побачити, як ці техніки працюють практично.

Перша техніка полягає в тому, щоб застосовувати результат уведення безпосередньо, не чекаючи відповіді від сервера. Це називається прогнозування на стороні клієнта. Однак, коли клієнт отримує оновлення від сервера, він повинен переконатися, що його прогноз був вірним. Якщо це не так, то йому потрібно просто змінити свій стан згідно з сервером, тому що сервер авторитарний. Ця техніка вперше була використана у Quake. Докладніше про неї можна прочитати у статті Quake Engine code review Фаб'єна Санглара [переклад на Хабре].

Другий набір техніки використовується для згладжування руху інших сутностей між двома оновленнями стану. Існує два способи вирішення цього завдання: інтерполяція та екстраполяція. У разі інтерполяції беруться два останні стани і показується перехід з одного до іншого. Її недолік у тому, що вона викликає невелику частку затримки, тому що клієнт завжди бачить те, що відбувалося у минулому. Екстраполяція полягає у прогнозуванні того, де зараз мають знаходитись сутності на підставі останнього стану, отриманого клієнтом. Її недолік у тому, що якщо сутність повністю змінює напрямок руху, то виникне велика похибка між прогнозом і реальною позицією.

Остання, найпросунута техніка, корисна лише у FPS — це компенсація лага. При використанні компенсації лага сервер враховує затримки клієнта, коли він стріляє в ціль. Наприклад, якщо гравець виконав хедшот на своєму екрані, але насправді його мета через затримку знаходилася в іншому місці, то було б нечесно відмовляти гравцеві в праві на вбивство через затримку. Тому сервер виконує перемотування часу тому, на той момент, коли гравець вистрілив, щоб симулювати, що бачив гравець на своєму екрані, і перевірити колізію між його пострілом та метою.

Гленн Фідлер (як завжди!) написав у 2004 році статтю Network Physics (2004), в якій заклав фундамент синхронізації симуляції фізики між сервером та клієнтом. 2014 року він написав нову серію статей Networking Physics, в якій описуються інші техніки для синхронізації симуляції фізики.

Також у wiki компанії Valve є дві статті, Source Multiplayer Networking и Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization у яких розглядається компенсація затримок.

Запобігання читерству

Існує дві основні техніки запобігання читерству.

Перша: ускладнення надсилання читерами шкідливих пакетів. Як сказано вище, добрим способом її реалізації є шифрування.

Друга: авторитарний сервер повинен отримувати лише команди/введення/дії. Клієнт не повинен мати можливості змінювати стан на сервері, крім надсилання введення. Тоді сервер щоразу при отриманні введення повинен перед застосуванням перевіряти його на допустимість.

Логіка програми: висновок

Рекомендую вам реалізувати спосіб симуляції великих затримок та низьких частот оновлення, щоб мати можливість протестувати поведінку своєї гри в поганих умовах, навіть коли клієнт та сервер запущені на одному комп'ютері. Це спростить реалізацію методик згладжування затримок.

Інші корисні ресурси

Якщо ви хочете вивчити інші ресурси, присвячені мережевим моделям, їх можна знайти тут:

  • Блог Гленна Фідлера — варто прочитати весь його блог, у ньому є безліч чудових статей. Тут зібрані всі статті з мережевих технологій.
  • Awesome Game Networking автора M. Fatih MAR - це докладний список статей та відео по мережевих двигунах відеоігор.
  • В wiki сабреддіта r/gamedev теж є безліч корисних посилань.

Джерело: habr.com

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