О сетевой модели в играх для начинающих
Последние две недели я работал над сетевым движком для своей игры. До этого я вообще ничего не знал о сетевых технологиях в играх, поэтому прочитал множество статей и провёл множество экспериментов, чтобы уяснить все концепции и иметь возможность написать собственный сетевой движок.
В этом руководстве я хотел бы поделиться с вами различными концепциями, которые вам нужно изучить перед написанием собственного игрового движка, а также самыми лучшими ресурсами и статьями для их изучения.
В целом существует два основных типа сетевых архитектур: 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 Cube
- GameNetworkingSockets компании Valve
Я не пробовал их все, но предпочтение отдаю ENet, потому что она проста в использовании и надёжна. Кроме того, у неё есть понятная документация и туториал для начинающих.
Транспортный протокол: заключение
Подведём итог: существует два основных транспортных протокола: TCP и UDP. TCP обладает множеством полезных особенностей: надёжность, сохранение порядка пакетов, обнаружение ошибок. У UDP всего этого нет, зато TCP по своей природе обладает повышенными задержками, недопустимыми для некоторых игр. То есть для обеспечения низких задержек можно создать собственный протокол на основе UDP или использовать библиотеку, реализующую транспортный протокол на UDP и адаптированную для многопользовательских видеоигр.
Выбор между TCP, UDP и библиотекой зависит от нескольких факторов. Во-первых, от потребностей игры: нужны ли ей низкие задержки? Во-вторых, от требований протокола приложения: нужен ли ему надёжный протокол? Как мы увидим из следующей части, можно создать протокол приложения, для которого вполне подойдёт ненадёжный протокол. Наконец, нужно ещё учитывать опытность разработчика сетевого движка.
У меня есть два совета:
- Максимально абстрагируйте транспортный протокол от остальной части приложения, чтобы его можно было легко заменить, не переписывая весь код.
- Не занимайтесь преждевременной оптимизацией. Если вы не специалист по сетям и не уверены, нужен ли вам собственный транспортный протокол на основе UDP, то можете начать с TCP или библиотеки, обеспечивающих надёжность, а затем протестировать и измерить производительность. Если возникают проблемы и вы уверены, что причина заключается в транспортном протоколе, то возможно настало время создавать собственный транспортный протокол.
В завершение этой части рекомендую вам прочитать Introduction to Multiplayer Game Programming Брайана Хука, в котором рассмотрено множество обсуждаемых здесь тем.
Протокол приложения
Теперь, когда мы можем обмениваться данными между клиентами и сервером, нужно решить, какие именно данные передавать и в каком формате.
Классическая схема заключается в том, что клиенты отправляют серверу ввод или действия, а сервер отправляет клиентам текущее игровое состояние.
Сервер отправляет не полное, а отфильтрованное состояние с сущностями, которые находятся рядом с игроком. Он делает это по трём причинам. Во-первых, полное состояние может быть слишком большим для передачи с высокой частотой. Во-вторых, клиентов в основном интересуют визуальные и аудиоданные, потому что бОльшая часть игровой логики симулируется на сервере игры. В-третьих, в некоторых играх игрок не должен знать определённых данных, например, позицию противника на другом конце карты, ведь в противном случае он может сниффить пакеты и точно знать, куда двигаться, чтобы его убить.
Сериализация
Первым шагом будет преобразование данных, которые мы хотим отправить (ввод или игровое состояние), в подходящий для передачи формат. Этот процесс называется сериализацией.
В голову сразу приходит мысль использовать человекочитаемый формат, например JSON или XML. Но это будет совершенно неэффективно и впустую займёт большую часть канала.
Вместо этого рекомендуется использовать двоичный формат, который намного более компактен. То есть пакеты будут содержать только несколько байтов. Здесь нужно учитывать проблему порядка байтов, который на разных компьютерах может отличаться.
Для сериализации данных можно использовать библиотеку, например:
- FlatBuffers компании Google
- Cap’n Proto компании Sandstorm
- cereal Шейна Гранта и Рэндольфа Вурхиса
Только убедитесь, что библиотека создаёт портируемые архивы и заботится о порядке байтов.
Альтернативным решением может быть самостоятельная реализация, она не особо сложна, особенно если в коде вы используете ориентированный на данные подход. Кроме того, она позволит вам выполнять оптимизации, которые не всегда возможны при использовании библиотеки.
Гленн Фидлер написал о сериализации две статьи: Reading and Writing Packets и Serialization Strategies.
Сжатие
Количество данных, передаваемых между клиентами и сервером, ограничено пропускной способностью канала. Сжатие данных позволит передавать в каждом снэпшоте больше данных, увеличить частоту обновления или просто снизить требования к каналу.
Битовая упаковка
Первая техника — это битовая упаковка. Она заключается в использовании ровно того количества битов, которое необходимо для описания нужной величины. Например, если у вас есть перечисление, которое может иметь 16 различных значений, то вместо целого байта (8 бит) можно использовать всего 4 бита.
Гленн Фидлер объясняет, как реализовать это, во второй части статьи Reading and Writing Packets.
Битовая упаковка особенно хорошо работает с дискретизацией, которая будет темой следующего раздела.
Дискретизация
Дискретизация — это техника сжатия с потерями, которая заключается в использовании для кодирования величины только подмножества возможных значений. Проще всего реализовать дискретизацию округлением чисел с плавающей запятой.
Гленн Фидлер (опять!) показывает, как применять дискретизацию на практике, в своей статье Snapshot Compression.
Алгоритмы сжатия
Следующей техникой будут алгоритмы сжатия без потерь.
Вот, на мой взгляд, три самых интересных алгоритма, которые нужно знать:
- Кодирование Хаффмана с заранее вычисленным кодом, которое чрезвычайно быстро и может давать хорошие результаты. Оно использовалось для сжатия пакетов в сетевом движке Quake3.
- zlib — алгоритм сжатия общего назначения, который никогда не увеличивает объём данных. Как можно увидеть здесь, он применялся во множестве областей применения. Для обновления состояний он может оказаться избыточным. Но он может и пригодиться, если вам нужно отправлять клиентам с сервера ассеты, длинные тексты или рельеф.
- Копирование длин серий — это, наверно, простейший алгоритм сжатия, но он очень эффективен для определённых типов данных, и может использоваться как этап предварительной обработки перед zlib. Он особенно подходит для сжатия рельефа, состоящего из тайлов или вокселей, в которых множество соседних элементов повторяется.
Дельта-сжатие
Последняя методика сжатия — это дельта-сжатие. Она заключается в том, что передаются только различия между текущим игровым состоянием и последним состоянием, полученным клиентом.
Впервые она была применена в сетевом движке Quake3. Вот две статьи, объясняющих способ её использования:
- The Quake3 Networking Model Брайана Хука
- Quake 3 Source Code Review: Network Model Фабьена Санглара [перевод статьи на Хабре, см. раздел «Сетевая модель»]
Гленн Фидлер также использовал её во второй части своей статьи Snapshot Compression.
Шифрование
Кроме того вам может понадобиться шифровать передачу информации между клиентами и сервером. На это есть несколько причин:
- приватность/конфиденциальность: сообщения могут быть прочитаны только получателем, и ни одному другому лицу, выполняющему сниффинг сети, не удастся их прочесть.
- аутентификация: человек, желающий исполнять роль игрока, должен знать его ключ.
- предотвращение читерства: злонамеренным игрокам будет намного сложнее создавать собственные пакеты для читерства, им придётся воспроизводить схему шифрования и находить ключ (который меняется при каждом соединении).
Я настойчиво рекомендую использовать для этого библиотеку. Предлагаю пользоваться libsodium, потому что она особенно проста и имеет отличные туториалы. Особо интересен туториал по обмену ключами, позволяющий генерировать новые ключи при каждом новом соединении.
Протокол приложения: заключение
На этом мы закончим с протоколом приложения. Я считаю, что сжатие совершенно необязательно и решение о его использовании зависит только от игры и требуемой пропускной способности канала. Шифрование, на мой взгляд, обязательно, но в первом прототипе можно обойтись без него.
Логика приложения
Теперь мы способны обновлять состояние в клиенте, но можем столкнуться с проблемами задержек. Игроку, выполнив ввод, нужно ждать обновления состояния игры от сервера, чтобы увидеть, какое воздействие он оказал на мир.
Более того, между двумя обновлениями состояния мир совершенно статичен. Если частота обновления состояний низка, то движения будут очень дёрганными.
Существует несколько техник, позволяющих снизить влияние этой проблемы, и в следующем разделе я о них расскажу.
Техники сглаживания задержек
Все описанные в этом разделе техники подробно рассмотрены в серии 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 тоже есть множество полезных ссылок.