Система стрельбы в SKWOLETS: предсказание, перемотка, пространственная сетка и регистрация попаданий

[PRE-ALPHA] демонстрация отмотки при пинге 250 ms

Привет! В прошлом посте я рассказывал о базовой серверной архитектуре, которая была связана с подключением игроков к серверу. Теперь хочу рассказать о системе стрельбы, но не в общих чертах, а на довольно узкую тему: симуляция пули и регистрация попаданий.

Регистрация попаданий — больная тема для тех, кто любит пострелять по сети. Даже в крупных AAA-проектах, таких как Counter-Strike или Arma, возникают проблемы с регистрацией попаданий при высоком пинге или падении серверного FPS. А иногда проблема банально может быть в кривом хитбоксе, когда, например, хитбокс головы не совпадает с головой персонажа.

Если попытаться сделать стрельбу одновременно отзывчивой для клиента, сервер-авторитетной и при этом сохранить нормальную баллистику, появляется довольно много отдельных задач.

К слову о баллистике — в CS её вообще нет, а проблемы с регистрацией попаданий всё равно появляются.

https://game-tournaments.com/csgo/news/51647
https://game-tournaments.com/csgo/news/51647

Итак, к сути.

Есть несколько проблем, которые напрямую связаны с сетью:

1. Нельзя заставлять клиента ждать сервер перед каждым выстрелом. Если пинг у клиента 230 мс, то при ожидании подтверждения каждый выстрел будет ощущаться с задержкой примерно в 0,23 секунды. Это очень заметно. Что уж говорить — некоторые люди способны отличить 120 FPS от 130, а тут задержка в почти четверть секунды.

2. Серверу нельзя доверять результат попадания от клиента, потому что это создаёт очевидную уязвимость для читерства.

3. После выстрела цель за время сетевой задержки может переместиться, особенно если она находится в движении. В момент, когда сервер получает информацию о выстреле, положение цели уже может отличаться от того, которое видел стрелок.

В этой статье я хочу показать, как я решил эту задачу и как отдельные системы собираются в единый пайплайн.

Что именно нужно решить

Первое, что я сделал, — разделил проблему стрельбы на несколько независимых частей.

На первый взгляд можно было бы сделать всё намного проще:

КЛИЕНТ │ │ Трассировка ▼ УРОН

Для одиночной игры этого может быть достаточно.

Но в сетевой игре такой подход сразу создаёт проблему: клиент фактически начинает сообщать серверу не только о том, что игрок нажал кнопку, но и о том, куда он попал.

Мне нужен был другой принцип:

Клиент может предсказывать выстрел, но результат выстрела принадлежит серверу.

Поэтому я разделил визуальную отзывчивость и авторитетную игровую логику.

Клиент отвечает за мгновенный отклик.

Сервер отвечает за проверку, симуляцию и результат попадания.

Почему пуля не является Actor*

*Actor в Unreal Engine — полноценный объект в мире со своим lifecycle, компонентами, Tick, сетевой логикой и другими причудами движка.

Одна из первых архитектурных проблем — количество объектов.

В сети полно роликов на тему выстрела, но львиная доля из них используют коробочный вариант через создание отдельного актора и прикрепление к нему компонента движения.

Но, если представить, что каждый выстрел создаёт отдельный AActor, то при большом количестве одновременно летящих пуль растёт и количество объектов, которые движку приходится обслуживать: их обновление, компоненты, тик, сетевая логика и тд.

Для нескольких пуль это не проблема.

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

Поэтому активные снаряды я храню как данные.

struct FProjectileRuntime { FVector Location; // позиция снаряда в мире FVector Velocity; // текущая скорость float Lifetime; // сколько уже живет };

А сами данные находятся в массиве:

TArray<FProjectileRuntime> Projectiles;​

И обрабатывает все это дело subsystem:

void UProjectileSubsystem::SimulateProjectiles(const float Step) { // логика }

Вот это уже другое дело!

Вместо:

100 пуль │ ▼ 100 Actors │ ▼ 100 независимых объектов

получается:

100 пуль │ ▼ одна сабсистема │ ▼ единая централизованная симуляция

Как симулируется баллистика

Следующий вопрос — что именно происходит с пулей на каждом шаге.

У неё есть скорость, гравитация, сопротивление воздуха и ветер.

В моей текущей реализации обновление скорости выглядит примерно так:

RT.Velocity += ( -ComputeDrag( RT.Velocity, Projectile.Static.DragFactor ) + FVector( 0, 0, ProjectileConstants::GRAVITY ) + RT.WindVelocity ) * Step; FVector Start = RT.Location; FVector End = Start + RT.Velocity * Step; // после обновления скорости рассчитывается новый отрезок траектории — от Start до End.

Здесь происходит несколько вещей.

Сначала рассчитывается изменение скорости.

На неё влияют:

Drag // сопротивление Gravity // гравитация Wind​ // ветер

После этого вычисляется новая позиция.

То есть упрощённо:

текущая скорость │ ├── gravity ├── drag └── wind │ ▼ новая скорость │ ▼ новая позиция

Именно здесь для меня математика из школьных учебников наконец начала выглядеть не как абстракция.

Пуля теперь умеет лететь по траектории и для нее кастом.

Почему данные боеприпаса вынесены отдельно

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

У разных типов боеприпасов могут отличаться:

Velocity // начальная скорость BulletWeight // вес пули (не патрона) BallisticCoefficient // баллистический коэффициент Lifetime // время жизни пули в воздухе

Поэтому параметры можно задавать через конфигурацию.

Конкретно:

{ "Type": "Ammo_762x39", "Velocity": 71800, // скорость в сантиметрах "BulletWeight": 7.9, // вес в граммах "BallisticCoefficient": 0.294, // эффективность сопротивления "Lifetime": 3.0 "Particle": { "ParticleSystem": "Tracer", // партикл трасера "SpawnDelayDistance": 100 // спавн на расстоянии в см } }

А уже runtime-часть работает с этими данными.

Это позволяет добавлять новые типы боеприпасов, не превращая сабсистему в огромный набор условий.

Также в девлоге можно подробнее посмотреть, как работает симуляция полёта пули и как на неё влияют различные факторы.

ЭНЕРГИЯ ПОПАДАНИЯ

В момент попадания сервер рассчитывает энергию пули в джоулях. Она зависит от массы пули и её текущей скорости. Масса пули берётся из конфига чуть выше.

Например, более тяжёлая пуля при той же скорости будет иметь больше энергии и нанесёт больше урона.

Больше масса → больше энергия → больше урон.

Зачем это надо?

Чтобы на дистанции 800 метров игроки думали о том, что пуля потеряет энергию, нанесет мало урона и выбирали калибр по случаю. В общем и целом, чтобы был баланс расстояния с калибром, да и просто потому что я люблю реализм.

P.S. Без фанатизма

Локальное предсказание

Чтобы игрок на своем экране в момент выстрела видел быструю реакцию на нажатие, нужно реализовать локальную логику.

Игрок нажал ЛКМ → увидел отдачу, вспышку, звук, вылет гильзы и инициировал локальную симуляцию пули и в тот же момент отправил запрос на сервер.

«Я выстрелил, вот мои данные для проверки».

Сервер делает валидацию и разрешает/запрещает выстрел.

Есть выстрел разрешен — сервер начинает свою симуляцию пули, но без визуальных эффектов.

Симуляция выполняется детерминированно, потому что данные одинаковые. Да float имеет погрешность, но всегда есть возможно перевести на double, но я пока этого не делаю — тесты показываю отличный детерминизм даже на дистанции 1км.

Но зачем тогда клиенту симулировать пулю, если сервер сам нанесет урон?

Для визуала трассера и попадания. Так как пули летят одинаково, то точки приземления у них одинаковые — т.е. клиент покажет нужный партикл и звук попадания, а сервер нанесет урон.

Сервер вообще не должен тратит CPU на эффекты. Это база.

Сама концепция локального предсказания конечно же придумана не мной, я лишь моддернизировал ее и оптимизировал.

Идею взял из этой статьи, кому интересно:

Важно отметить, что в статье реализовано как раз через дорогостоящий Actor. Почему это не масштабируемый вариант я описал в подтеме выше.

Двигаемся дальше.

Почему серверу недостаточно обычного Trace

Отсюда начинается по моему мнение самое интересное.

Представим:

Игрок нажал ЛКМ в момент T0 (начальный момент времени).

Но сервер получил этот запрос в момент T0 + задержка.

За это время персонаж мог переместиться.

Например:

T0 │ ИГРОК X │ │ ▼ T0 + 150 ms │ ИГРОК X

Если сервер просто выполнит трассировку по текущей позиции персонажа, то он будет проверять уже не то состояние, которое видел стрелок в момент выстрела.

В статье выше он реализовал задержку, но мне напрочь не понравилась эта идея, потому что у меня нет посохов и никто колдовать не будет. Мои ребята с огнестрельным оружием, а это не шутки. И я впринципе не смогу никаких задержек перед выстрелом придумать, даже если очень захочу.

Так я начал вникать и искать информацию о том, каким образом мне отмотать время назад, чтобы сервер всегда точно знал где был игрок и в какой позе в течении последней полсекунды, т.к. клиенту доверять нельзя в сети.

Но прежде чем делать отмотку, серверу ещё нужно понять, кого вообще имеет смысл проверять.

И здесь появляется Spatial Grid (пространственная сетка).

Spatial Grid — поиск кандидатов

Самая очевидная реализация могла бы выглядеть так:

for (APlayerCharacter* Player : AllPlayers) { CheckProjectileAgainstPlayer(Player); }

Просто перебрать всех игроков и по каждому вычислить дистанцию.

Но делать это для каждого снаряда не вариант — дорого.

Если одновременно летит много пуль, количество потенциальных проверок растёт пропорционально.

Поэтому я использую Spatial Grid как грубую проверку (фазу).

Упрощённо:

Пули │ ▼ Spatial Grid пересечения │ ▼ Хитбоксы │ ├── Игрок А ├── Игрок Б └── Игрок В
демонстрация пространственной сетки с 5 бегущими игроками

То есть пространственная сетка не отвечает на вопрос:

«Попала ли пуля в игрока?»

Она отвечает на другой вопрос:

«Какие объекты вообще могут находиться на пути этой пули?»

Сначала дешёво сокращаем пространство поиска.

Потом уже выполняем дорогую проверку.

Получается схема:

Грубая фаза │ ▼ Выбор кандидата │ ▼ Узкая фаза │ ▼ Актуальные коллизии

В моём случае грубая фаза — пространственная сетка, а дальнейшая проверка выполняется уже по историческому снимку хитбоксов.

Rewind (отмотка)

Я сделал небольшой конструктор отмотки, чтобы наглядно показать, как работает моя идея и как она реализована.

Теперь у сервера есть только те кандидаты, которые действительно могут пересекаться с траекторией снаряда.

Но остаётся проблема времени.

Для этого я храню историческое состояние.

Snapshot (снимок) содержит:

Timestamp // временная метка Component Transform // трансформ персонажа Bone Transforms // трансформы костей

Снимки сохраняются во времени. Например, на моем сервере, скелет тикает 30 раз в секунду — это 12 снимков, так как история хранится до 400 ms. А больше мне и не нужно, я и так беру с запасом, потому что при пинге за 300 ms игрока кикнет через 10 секунд.

ПРОШЛОЕ │ ├── Snapshot ├── Snapshot ├── Snapshot ├── Snapshot ├── .... │ ▼ СЕЙЧАС

Когда приходит выстрел, сервер определяет момент, к которому нужно отмотать состояние цели.

Дальше выполняется локальная перемотка, логика такая:

Текущее состояние │ ▼ Сохранить текущее состояние │ ▼ Восстановить историческую позу │ ▼ Пересечение коллизий │ ▼ Восстановить текущее состояние

Очень важно, что это не означает, что игровой мир действительно "перематывается назад".

Перематывается только состояние, которое нужно для регистрации попадания.

После проверки всё восстанавливается.

Идею прочитал в этой статье, но реализовал по своему, потому что реализация в статье примитивна и не дает точности по конечностям, так как там проверяется BOX персонажа, а не хитбоксы. Но автор об этом предупреждает.

В моих тестах полный цикл — отмотка + восстановление — в среднем занимает:

Average: ~0.65 μs P95: ~0.9 μs

Не удивляйтесь цифрам, это чистый RAII C++ класс, который создан для перемотки и больше ничего не умеет, поэтому такой шустрый.

А сохранение 1 снимка:

Average: ~1.698 μs P95: ~2.100 μs

Замер делал через 10000 итераций в рантайме на i9-13900F, еще один тест на E5-2697 v3. На E5 показывает на x1.4-x1.8 выше значения, и это естественно, потому что он конкретный мамонт. Но это сделано осознанно, чтобы понимать, как будет работать на слабом железе.

Для меня это было важным результатом, потому что отмотка изначально выглядела гораздо более тяжёлой операцией, чем оказалось на практике.

Сама запись исторического состояния получается немного дороже, поэтому здесь появился смысл оптимизировать уже не саму отмотку, а частоту обновления исторических данных.

Для этого я добавил состояние Dormant (спящий режим).

Если объект некоторое время не требует обновления, я отключаю обновление серверного скелета и его тик:

void FHitboxProxy::SetDormant(const bool bDormant, const float DormantTime) { if (!RewindableMesh) { return; } RewindableMesh->bNoSkeletonUpdate = bDormant; RewindableMesh->SetComponentTickEnabled(!bDormant); TimeUntilDormant = bDormant ? 0.0f : DormantTime; }

То есть: если объект неактивен, нет смысла постоянно пересчитывать его скелет на сервере только ради создания новых исторических поз.

Когда объект снова становится активным, TimeUntilDormant переменная используется как период, в течение которого он снова обновляется.

При этом сам серверный скелет специально оптимизирован под эту задачу: он состоит всего из 20 костей и 12 примитивов, используемых для хитбоксов, тогда как скелет клиентского персонажа может содержать более 50 костей.

Реальные скелеты из игры
Реальные скелеты из игры

А запускает обновление серверного скелета фактически сама пуля: когда ее траектория пересекается с потенциальной целью, сервер достает исторические снимки и выполняет отмотку.

SetDormant(false, 60.0f); // запуск обновления скелета на 60 сек

Наконец-то регистрация попадания

После отмотки можно выполнять уже нормальную collision-проверку по историческому снимку цели.

В упрощённом виде пайплайн выглядит так:

Снаряд │ ▼ Пространственная сетка │ ▼ Потенциальные хитбоксы │ ▼ Отмотка │ ▼ LineTraceMulti* // трассировка │ ▼ Сортировать результаты по порядку путей │ ▼ Проникновение / Блокирование │ ▼ Регистрация попаданий

Я использую *LineTraceMulti, потому что одна пуля не обязательно должна остановиться на первом пересечении.

Так как, на её пути может оказаться поверхность, которую разрешено пробить, например, сельский уличный туалет, его можно с любого калибра прошить.

Поэтому результаты трассировки обрабатываются последовательно.

Схема логики:

Попадание │ ├── Проникаемое? │ │ │ ├── ДА → продолжить полет │ │ │ └── НЕТ → остановить │ ▼ следующее попадание

При этом порядок попаданий важен.

Меня интересует не просто набор пересечений, а порядок вдоль траектории снаряда.

Поэтому результаты сортируются по времени пересечения.

Так сервер может последовательно обработать:

Снаряд │ ▼ Стена │ ▼ Игрок │ ▼ ...

и решить, может ли снаряд продолжить движение или должен завершить свою симуляцию.

P.S. Тут логично было бы показать реализацию рикошета, но она пока настолько сырая, что показывать её без цензуры я не решился. 😄

Валидация выстрела

Предсказание решает проблему отзывчивости.

Но оно не должно превращаться в доверие клиенту.

Перед тем как сервер примет выстрел в авторитетную симуляцию, он проверяет входные данные.

Основные вещи, которые серверу имеет смысл контролировать:

Временная метка Позиция ствола Интервал между выстрелами Угол камеры

Например, оружие может иметь интервал:

0.1 сек

Это соответствует:

600 RPM

Но в рантайме мне удобнее работать именно с интервалом между выстрелами.

Тогда проверка становится естественной:

if (CurrentTime - LastShotTime < FireInterval) { RejectShot(); }

Это намного удобнее для самой игровой логики, чем постоянно пересчитывать RPM.

Проверка направления

Сервер уже получает необходимую информацию о направлении взгляда через существующий сетевой movement flow Unreal Engine. Поэтому мне не пришлось делать отдельную систему репликации камеры только ради стрельбы.

Я могу строить валидацию вокруг данных, которые сервер уже получает.

В итоге сервер получает запрос примерно такого смысла:

Player // Кто стрелял? Timestamp // В какое время стрелял? Muzzle position // Откуда стрелял? Shot direction // Куда стрелял? Weapon state // Оружие могло стрелять?

и уже сам решает, может ли этот выстрел существовать.

Полный пайплайн

Теперь все части можно собрать вместе.

КЛИЕНТ │ │ Выстрел ▼ Клиентское предсказание │ │ Отправка на сервер ▼ СЕРВЕР │ ▼ Валидация выстрела │ ▼ Симуляция пули │ ▼ Сканирование в пространственной сетке │ ▼ Пересеченные хитбоксы │ ▼ Отмотка │ ▼ Трассировка коллизии │ ▼ Регистрация попадания │ ▼ УРОН

Клиент

Отвечает за:

Ввод Предсказание Мгновенная обратная связь

Сабсистема снарядов

Отвечает за:

Состояние снаряда Скорость Гравитация Сопротивление воздуха Ветер Время жизни

Пространственная сетка

Отвечает за:

Фильтрация кандидатов

Отмотка

Отвечает за:

Историческое целевое состояние

Сервер

Отвечает за:

Валидация Регистрация попаданий Урон

То есть у меня нет одной огромной функции FireWeapon(), внутри которой происходит вообще всё.

Система разбита на отдельные части, каждая из которых отвечает за свою задачу.

Еще есть что оптимизировать, но фундамент — вот он.

Завершаю

Точных реализаций я здесь не привожу — это уже детали, которые я оставляю за кадром. Но сам принцип я постарался объяснить максимально понятно, потому что именно он и есть суть.

В начале эта система была намного проще.

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

Потом я начал переносить ответственность на сервер и постепенно добавлять недостающие части.

Сейчас мне нравится именно то, что это не одна большая система.

Пуля не является Actor.

Сетка не занимается попаданием.

Отмотка не занимается уроном.

Предсказание не решает, было ли попадание.

Каждая часть делает свою работу, а сервер в конце собирает результат.

P.S. Буду рад вашему мнению и конструктивной критике. Особенно интересно узнать, как подобные задачи решаются в других сетевых проектах.

6