Система стрельбы в SKWOLETS: предсказание, перемотка, пространственная сетка и регистрация попаданий
Привет! В прошлом посте я рассказывал о базовой серверной архитектуре, которая была связана с подключением игроков к серверу. Теперь хочу рассказать о системе стрельбы, но не в общих чертах, а на довольно узкую тему: симуляция пули и регистрация попаданий.
Регистрация попаданий — больная тема для тех, кто любит пострелять по сети. Даже в крупных AAA-проектах, таких как Counter-Strike или Arma, возникают проблемы с регистрацией попаданий при высоком пинге или падении серверного FPS. А иногда проблема банально может быть в кривом хитбоксе, когда, например, хитбокс головы не совпадает с головой персонажа.
Если попытаться сделать стрельбу одновременно отзывчивой для клиента, сервер-авторитетной и при этом сохранить нормальную баллистику, появляется довольно много отдельных задач.
К слову о баллистике — в CS её вообще нет, а проблемы с регистрацией попаданий всё равно появляются.
Итак, к сути.
Есть несколько проблем, которые напрямую связаны с сетью:
1. Нельзя заставлять клиента ждать сервер перед каждым выстрелом. Если пинг у клиента 230 мс, то при ожидании подтверждения каждый выстрел будет ощущаться с задержкой примерно в 0,23 секунды. Это очень заметно. Что уж говорить — некоторые люди способны отличить 120 FPS от 130, а тут задержка в почти четверть секунды.
2. Серверу нельзя доверять результат попадания от клиента, потому что это создаёт очевидную уязвимость для читерства.
3. После выстрела цель за время сетевой задержки может переместиться, особенно если она находится в движении. В момент, когда сервер получает информацию о выстреле, положение цели уже может отличаться от того, которое видел стрелок.
В этой статье я хочу показать, как я решил эту задачу и как отдельные системы собираются в единый пайплайн.
Что именно нужно решить
Первое, что я сделал, — разделил проблему стрельбы на несколько независимых частей.
На первый взгляд можно было бы сделать всё намного проще:
Для одиночной игры этого может быть достаточно.
Но в сетевой игре такой подход сразу создаёт проблему: клиент фактически начинает сообщать серверу не только о том, что игрок нажал кнопку, но и о том, куда он попал.
Мне нужен был другой принцип:
Клиент может предсказывать выстрел, но результат выстрела принадлежит серверу.
Поэтому я разделил визуальную отзывчивость и авторитетную игровую логику.
Клиент отвечает за мгновенный отклик.
Сервер отвечает за проверку, симуляцию и результат попадания.
Почему пуля не является Actor*
*Actor в Unreal Engine — полноценный объект в мире со своим lifecycle, компонентами, Tick, сетевой логикой и другими причудами движка.
Одна из первых архитектурных проблем — количество объектов.
В сети полно роликов на тему выстрела, но львиная доля из них используют коробочный вариант через создание отдельного актора и прикрепление к нему компонента движения.
Но, если представить, что каждый выстрел создаёт отдельный AActor, то при большом количестве одновременно летящих пуль растёт и количество объектов, которые движку приходится обслуживать: их обновление, компоненты, тик, сетевая логика и тд.
Для нескольких пуль это не проблема.
Но у меня в игре одновременно могут существовать сотни, а то и тысячи активных снарядов в воздухе, мне не хотелось превращать каждый выстрел в полноценный игровой обьект, потому что я заранее думаю игроках, которые сидят на среднем железе.
Поэтому активные снаряды я храню как данные.
А сами данные находятся в массиве:
И обрабатывает все это дело subsystem:
Вот это уже другое дело!
Вместо:
получается:
Как симулируется баллистика
Следующий вопрос — что именно происходит с пулей на каждом шаге.
У неё есть скорость, гравитация, сопротивление воздуха и ветер.
В моей текущей реализации обновление скорости выглядит примерно так:
Здесь происходит несколько вещей.
Сначала рассчитывается изменение скорости.
На неё влияют:
После этого вычисляется новая позиция.
То есть упрощённо:
Именно здесь для меня математика из школьных учебников наконец начала выглядеть не как абстракция.
Пуля теперь умеет лететь по траектории и для нее кастом.
Почему данные боеприпаса вынесены отдельно
Я также не хотел зашивать параметры непосредственно в код, чтобы серверовладельцы могли добавлять моды.
У разных типов боеприпасов могут отличаться:
Поэтому параметры можно задавать через конфигурацию.
Конкретно:
А уже runtime-часть работает с этими данными.
Это позволяет добавлять новые типы боеприпасов, не превращая сабсистему в огромный набор условий.
Также в девлоге можно подробнее посмотреть, как работает симуляция полёта пули и как на неё влияют различные факторы.
ЭНЕРГИЯ ПОПАДАНИЯ
В момент попадания сервер рассчитывает энергию пули в джоулях. Она зависит от массы пули и её текущей скорости. Масса пули берётся из конфига чуть выше.
Например, более тяжёлая пуля при той же скорости будет иметь больше энергии и нанесёт больше урона.
Больше масса → больше энергия → больше урон.
Зачем это надо?
Чтобы на дистанции 800 метров игроки думали о том, что пуля потеряет энергию, нанесет мало урона и выбирали калибр по случаю. В общем и целом, чтобы был баланс расстояния с калибром, да и просто потому что я люблю реализм.
P.S. Без фанатизма
Локальное предсказание
Чтобы игрок на своем экране в момент выстрела видел быструю реакцию на нажатие, нужно реализовать локальную логику.
Игрок нажал ЛКМ → увидел отдачу, вспышку, звук, вылет гильзы и инициировал локальную симуляцию пули и в тот же момент отправил запрос на сервер.
«Я выстрелил, вот мои данные для проверки».
Сервер делает валидацию и разрешает/запрещает выстрел.
Есть выстрел разрешен — сервер начинает свою симуляцию пули, но без визуальных эффектов.
Симуляция выполняется детерминированно, потому что данные одинаковые. Да float имеет погрешность, но всегда есть возможно перевести на double, но я пока этого не делаю — тесты показываю отличный детерминизм даже на дистанции 1км.
Но зачем тогда клиенту симулировать пулю, если сервер сам нанесет урон?
Для визуала трассера и попадания. Так как пули летят одинаково, то точки приземления у них одинаковые — т.е. клиент покажет нужный партикл и звук попадания, а сервер нанесет урон.
Сервер вообще не должен тратит CPU на эффекты. Это база.
Сама концепция локального предсказания конечно же придумана не мной, я лишь моддернизировал ее и оптимизировал.
Идею взял из этой статьи, кому интересно:
Важно отметить, что в статье реализовано как раз через дорогостоящий Actor. Почему это не масштабируемый вариант я описал в подтеме выше.
Двигаемся дальше.
Почему серверу недостаточно обычного Trace
Отсюда начинается по моему мнение самое интересное.
Представим:
Игрок нажал ЛКМ в момент T0 (начальный момент времени).
Но сервер получил этот запрос в момент T0 + задержка.
За это время персонаж мог переместиться.
Например:
Если сервер просто выполнит трассировку по текущей позиции персонажа, то он будет проверять уже не то состояние, которое видел стрелок в момент выстрела.
В статье выше он реализовал задержку, но мне напрочь не понравилась эта идея, потому что у меня нет посохов и никто колдовать не будет. Мои ребята с огнестрельным оружием, а это не шутки. И я впринципе не смогу никаких задержек перед выстрелом придумать, даже если очень захочу.
Так я начал вникать и искать информацию о том, каким образом мне отмотать время назад, чтобы сервер всегда точно знал где был игрок и в какой позе в течении последней полсекунды, т.к. клиенту доверять нельзя в сети.
Но прежде чем делать отмотку, серверу ещё нужно понять, кого вообще имеет смысл проверять.
И здесь появляется Spatial Grid (пространственная сетка).
Spatial Grid — поиск кандидатов
Самая очевидная реализация могла бы выглядеть так:
Просто перебрать всех игроков и по каждому вычислить дистанцию.
Но делать это для каждого снаряда не вариант — дорого.
Если одновременно летит много пуль, количество потенциальных проверок растёт пропорционально.
Поэтому я использую Spatial Grid как грубую проверку (фазу).
Упрощённо:
То есть пространственная сетка не отвечает на вопрос:
«Попала ли пуля в игрока?»
Она отвечает на другой вопрос:
«Какие объекты вообще могут находиться на пути этой пули?»
Сначала дешёво сокращаем пространство поиска.
Потом уже выполняем дорогую проверку.
Получается схема:
В моём случае грубая фаза — пространственная сетка, а дальнейшая проверка выполняется уже по историческому снимку хитбоксов.
Rewind (отмотка)
Я сделал небольшой конструктор отмотки, чтобы наглядно показать, как работает моя идея и как она реализована.
Теперь у сервера есть только те кандидаты, которые действительно могут пересекаться с траекторией снаряда.
Но остаётся проблема времени.
Для этого я храню историческое состояние.
Snapshot (снимок) содержит:
Снимки сохраняются во времени. Например, на моем сервере, скелет тикает 30 раз в секунду — это 12 снимков, так как история хранится до 400 ms. А больше мне и не нужно, я и так беру с запасом, потому что при пинге за 300 ms игрока кикнет через 10 секунд.
Когда приходит выстрел, сервер определяет момент, к которому нужно отмотать состояние цели.
Дальше выполняется локальная перемотка, логика такая:
Очень важно, что это не означает, что игровой мир действительно "перематывается назад".
Перематывается только состояние, которое нужно для регистрации попадания.
После проверки всё восстанавливается.
Идею прочитал в этой статье, но реализовал по своему, потому что реализация в статье примитивна и не дает точности по конечностям, так как там проверяется BOX персонажа, а не хитбоксы. Но автор об этом предупреждает.
В моих тестах полный цикл — отмотка + восстановление — в среднем занимает:
Не удивляйтесь цифрам, это чистый RAII C++ класс, который создан для перемотки и больше ничего не умеет, поэтому такой шустрый.
А сохранение 1 снимка:
Замер делал через 10000 итераций в рантайме на i9-13900F, еще один тест на E5-2697 v3. На E5 показывает на x1.4-x1.8 выше значения, и это естественно, потому что он конкретный мамонт. Но это сделано осознанно, чтобы понимать, как будет работать на слабом железе.
Для меня это было важным результатом, потому что отмотка изначально выглядела гораздо более тяжёлой операцией, чем оказалось на практике.
Сама запись исторического состояния получается немного дороже, поэтому здесь появился смысл оптимизировать уже не саму отмотку, а частоту обновления исторических данных.
Для этого я добавил состояние Dormant (спящий режим).
Если объект некоторое время не требует обновления, я отключаю обновление серверного скелета и его тик:
То есть: если объект неактивен, нет смысла постоянно пересчитывать его скелет на сервере только ради создания новых исторических поз.
Когда объект снова становится активным, TimeUntilDormant переменная используется как период, в течение которого он снова обновляется.
При этом сам серверный скелет специально оптимизирован под эту задачу: он состоит всего из 20 костей и 12 примитивов, используемых для хитбоксов, тогда как скелет клиентского персонажа может содержать более 50 костей.
А запускает обновление серверного скелета фактически сама пуля: когда ее траектория пересекается с потенциальной целью, сервер достает исторические снимки и выполняет отмотку.
Наконец-то регистрация попадания
После отмотки можно выполнять уже нормальную collision-проверку по историческому снимку цели.
В упрощённом виде пайплайн выглядит так:
Я использую *LineTraceMulti, потому что одна пуля не обязательно должна остановиться на первом пересечении.
Так как, на её пути может оказаться поверхность, которую разрешено пробить, например, сельский уличный туалет, его можно с любого калибра прошить.
Поэтому результаты трассировки обрабатываются последовательно.
Схема логики:
При этом порядок попаданий важен.
Меня интересует не просто набор пересечений, а порядок вдоль траектории снаряда.
Поэтому результаты сортируются по времени пересечения.
Так сервер может последовательно обработать:
и решить, может ли снаряд продолжить движение или должен завершить свою симуляцию.
P.S. Тут логично было бы показать реализацию рикошета, но она пока настолько сырая, что показывать её без цензуры я не решился. 😄
Валидация выстрела
Предсказание решает проблему отзывчивости.
Но оно не должно превращаться в доверие клиенту.
Перед тем как сервер примет выстрел в авторитетную симуляцию, он проверяет входные данные.
Основные вещи, которые серверу имеет смысл контролировать:
Например, оружие может иметь интервал:
0.1 сек
Это соответствует:
600 RPM
Но в рантайме мне удобнее работать именно с интервалом между выстрелами.
Тогда проверка становится естественной:
Это намного удобнее для самой игровой логики, чем постоянно пересчитывать RPM.
Проверка направления
Сервер уже получает необходимую информацию о направлении взгляда через существующий сетевой movement flow Unreal Engine. Поэтому мне не пришлось делать отдельную систему репликации камеры только ради стрельбы.
Я могу строить валидацию вокруг данных, которые сервер уже получает.
В итоге сервер получает запрос примерно такого смысла:
и уже сам решает, может ли этот выстрел существовать.
Полный пайплайн
Теперь все части можно собрать вместе.
Клиент
Отвечает за:
Сабсистема снарядов
Отвечает за:
Пространственная сетка
Отвечает за:
Отмотка
Отвечает за:
Сервер
Отвечает за:
То есть у меня нет одной огромной функции FireWeapon(), внутри которой происходит вообще всё.
Система разбита на отдельные части, каждая из которых отвечает за свою задачу.
Еще есть что оптимизировать, но фундамент — вот он.
Завершаю
Точных реализаций я здесь не привожу — это уже детали, которые я оставляю за кадром. Но сам принцип я постарался объяснить максимально понятно, потому что именно он и есть суть.
В начале эта система была намного проще.
Сервер доверял клиенту больше, чем должен был, а сама стрельба была построена вокруг гораздо более простого подхода.
Потом я начал переносить ответственность на сервер и постепенно добавлять недостающие части.
Сейчас мне нравится именно то, что это не одна большая система.
Пуля не является Actor.
Сетка не занимается попаданием.
Отмотка не занимается уроном.
Предсказание не решает, было ли попадание.
Каждая часть делает свою работу, а сервер в конце собирает результат.
P.S. Буду рад вашему мнению и конструктивной критике. Особенно интересно узнать, как подобные задачи решаются в других сетевых проектах.