Без права на ошибку
В прошлой публикации мы уже бессовестно обманывали пользователя, когда брали безупречную с позиций физики проверку соприкосновения с землей, растягивали её на пару лишних миллисекунд и позволяли аватару совершить прыжок уже после того, как он фактически оторвался от поверхности. Шли на этот шаг вовсе не из-за профнепригодности нашего физика-теоретика, не умеющего запрограммировать элементарный коллижн, а исключительно ради комфорта игрока, которому совершенно необязательно знать всю эту техническую подноготную.
В многопользовательских проектах применяется похожий трюк, только лукавить приходится уже не с пространственными координатами, а с хронологией: если coyote time — это крошечная ложь о границах платформ, то компенсация задержки (lag compensation) — это деликатное заблуждение относительно точного времени существования игровой вселенной.
Здесь ситуация развивается по схожему сценарию: сервер может быть сто раз прав в том, что выстрел ушел в «молоко», но геймер все равно будет убежден, что система бессовестно украла у него заслуженный фраг. Разочаровывать аудиторию и портить рейтинг отрицательными отзывами мы, разумеется, не будем.
Призрачные попадания
Вообразите внутреннее тестирование мультиплеерного шутера, где один из участников находится, скажем, во Франкфурте, обладая вполне комфортным пингом в 78 миллисекунд. Во время сессии он совершает два отчетливых хедшота, прекрасно видя их на собственном мониторе, однако игровой движок оба раза выносит вердикт: мимо.
Последующий анализ серверных логов подтверждает правоту машины: попадания в голову действительно не было. Игрок визуально оценил обстановку (затратив на реакцию время), нажал левую клавишу мыши, после чего пакет с информацией о выстреле отправился странствовать по сети. Пока данные шли до пункта назначения, противник успел сместиться, и прибывший на сервер луч зафиксировал вражескую голову примерно в 0.4 метрах левее точки прицеливания.
С точки зрения серверной логики всё безупречно: raycast не задел хитбокс, значит, регистрации урона быть не может. Баг можно закрывать с формулировкой «works as designed», только вот геймер с таким вердиктом категорически не согласен. И это идеальный момент для параллели с предыдущей статьей: там расходились виртуальное и человеческое восприятие, а теперь в эту цепочку добавился еще и сетевой фактор.
В офлайн-режиме существует единая и неделимая реальность, тогда как в сетевой игре мир моментально расщепляется на несколько временных версий, каждая из которых считает себя абсолютной истиной.
Для пользователя подлинным является то, что отрисовывается на его дисплее, а для сервера — то, что просчитывается на текущем тике. Между ними пролегает сеть, способная брать две изначально тождественные сущности и на какое-то время убеждать нас в их радикальном отличии.
Как следствие, у участников с пингом выше 40 миллисекунд примерно 12% выстрелов в прототипе вообще не пересекали серверные хитбокс-модели (против менее чем 2% в локальной версии). Из-за этого дежурная фраза «у тебя просто высокий пинг» из проблемы игрока мгновенно превращается в головную боль разработчика.
CLIENT SERVER
target target
O O
| |
| |
| |
SHOOT! |
| |
+---------- packet ------------------->|
|
v
target moved
RAYCAST
|
X MISS

Где же прячется противник?

Вопрос «где находится оппонент прямо сейчас?» в мультиплеере на редкость многовариантен. Мы смотрим на него в текущую секунду: он там, где отображается на мониторе, или там, где его фиксирует сервер? А может, в той точке, где он находился десятки миллисекунд назад, когда клиент получил последний снапшот?
Ирония в том, что все эти три варианта абсолютно верны одновременно. Клиент не транслирует серверную действительность с абсолютной точностью, иначе перемещения удаленных персонажей превратились бы в невыносимое дергание: пакет пришел — модель здесь, следующий пакет — она резко телепортировалась дальше. Вместо плавного экшена игрок наблюдал бы парад сетевых задержек, поэтому клиент интерполирует данные, сглаживая переходы.
Получив от сервера позиции за несколько последовательных тактов, клиент плавно передвигает модельку между ними, удерживая её в небольшом временном «прошлом». Если интерполяционная задержка выставлена на 100 миллисекунд, картинка на экране демонстрирует мир, который сервер покинул уже мгновение назад.
Это стандартный паттерн клиент-серверной архитектуры, к которому мы и стремимся. Проблема возникает ровно в тот момент, когда человек нажимает на спуск: поразить он хочет ту картинку, которую видит в данный момент, но сервер принимает пакет с опозданием и сверяет его со своей, уже убежавшей вперед реальностью. Выходит абсурд: выстрел совершен в прошлом, валидируется в настоящем, а стрелок ждет, что сервер телепатически догадается о его визуальном опыте.
TIME ------------------------------------------------------------>
SERVER [100]------[101]------[102]------[103]------[104]
^
|
shot arrives
CLIENT [100]------[101]
^
|
SHOOT!
CLIENT SEES O
|
TARGET
SERVER NOW O
|
TARGET
Если сервер не вмешается, мы получим классическое «я попал, но игра врет». (Протестировать интерактивно). Прицеливаться приходится в видимую цель, наблюдая, как пинг превращает честный выстрел в промах.
Строим машину времени

Наиболее очевидный выход — заставить сервер коллекционировать прошлое. На каждом тике мы сохраняем критически важные данные вроде хитбоксов персонажей и складываем эти снапшоты в кольцевой буфер. Вместо актуального среза у нас появляется историческая летопись: при частоте 60 Гц сохранение 64 тиков обеспечивает примерно 1066 миллисекунд предыстории.
Теперь сервер способен ответить на вопрос: «Где этот конкретный боец находился секунду назад?». Если у модели 18 коллизионных зон по 32 байта, один снапшот весит 576 байт; для 32 игроков на 64 тика вся история займет чуть больше мегабайт памяти — сущий пустяк для современного железа.
Самое увлекательное начинается дальше: хранить архив легко, а вот выбрать из него нужный таймфрейм для конкретной пули — задача нетривиальная. Мы оперируем примерно следующей структурой (код исключительно демонстрационный):
struct FHitboxSnapshot{
uint32 Tick;
Capsule Hitboxes[18];
};
Обрабатывая выстрел, мы отказываемся от прямого запроса Raycast(CurrentWorld). Сначала мы вычисляем, в каком именно моменте прошлого должен существовать целевой мир. Вопрос трансформируется из «пересек ли луч хитбокс?» в «пересек ли луч хитбокс в той исторической конфигурации, которую наблюдал стрелок?». Схематически это выглядит так:
NOW
|
v
SERVER -----------------+----------------------------->
|
target
O
/ / / X MISS
<---- REWIND ----
SERVER -----------------+----------------------------->
|
old target
O
/ / / / / HIT
Базовая на вид хронологическая проверка превращается в архитектурную задачу. Опробовать самому (включая и отключая rewind-компенсацию для оценки hit rate).

На какую глубину откатывать симуляцию?
Первое желание разработчика — просто уменьшить текущий тик на величину пинга. Пусть длительность тика составляет 100 мс, а пинг равен 78 мс — казалось бы, откатимся на один такт назад. Но сетевой пинг — это круговой показатель (RTT), а нас интересует исключительно время доставки враждебного пакета до сервера, поэтому применяется формула:
rewindTime = RTT / 2 + interpolationDelay;
При пинге 78 мс и буфере интерполяции в 100 мс получаем:
REWIND
|
+-------------+-------------+
| |
RTT / 2 interpolation
| delay
| |
39 ms 100 ms
| |
+-------------+-------------+
|
v
139 ms
Иными словами, проверка столкновения происходит в состоянии хитбоксов, актуальном 139 миллисекунд назад. Доверять здесь клиентским меткам времени нельзя: иначе злоумышленник сможет заявить, что его задержка интерполяции равна двум секундам, и сервер услужливо сместит всех остальных игроков в эпоху плейстоцена.
Поэтому величину отката сервер вычисляет самостоятельно. Сам пинг также не является константой: сеть постоянно флуктуирует, поэтому разумно использовать скользящую статистику по последним 20 ответам, беря значения в диапазоне между 25-м и 75-м перцентилями, а при сильных скачках — округлять показатели, избегая попыток компенсировать каждый микролаг.
Чрезмерная компенсация реальных задержек приводит к курьезным казусам: обладатели ужасного соединения начинают стрелять практически из глубокого прошлого с откатами на полсекунды и более. Представьте жертву, которая давно забежала за угол и укрылась от огня, но внезапно получает смертельный хедшот — именно так выглядит перекомпенсированный пинг.
Пощупать своими руками (регулируя RTT и интерполяцию для наглядности формирования rewindTime).

Честная ложь

Логичный вопрос: если мы принципиально готовы крутить время назад ради удобства стрелка, почему бы не делать это без ограничений? Почему игроку с пингом 500 мс не предоставить те же привилегии, что и обладателю низких задержек?
Потому что в таком случае задержка сети трансформируется из изъяна в чистое имбовое преимущество. При экстремально высоком пинге возникают ситуации, когда противник выбегает из-за преграды, вы его замечаете, открываете огонь и скрываетесь обратно. В вашем заторможенном мире враг все еще находится на открытой позиции, хотя сервер зафиксировал его укрытие уже секунду назад.
Если сервер слепо верит историческим данным, лагающий игрок сможет безнаказанно поражать цели, которые с точки зрения большинства участников давно находятся в безопасности. Мы избавили от фрустрации нападающего, но породили жесточайшую несправедливость для обороняющегося. Именно поэтому окно ревинда на сервере приходится жестко лимитировать.
PAST NOW
| |
v v
+-----------------------------------------------------------+
|<-------------------- history ---------------------------->|
|
|<------ 200 ms ------>|
|
+ MAX REWIND
В идеальных условиях порог не должен превышать 200 миллисекунд. Откуда взялась эта цифра? Это эмпирический компромисс, выведенный из жалоб комьюнити. При 250 мс резко возрастает поток претензий на убийства сквозь стены; при 150 мс страдают хедшоты у игроков с посредственным соединением. Универсальной формулы для lag compensation не существует.
Попробовать на живом примере (спрятав цель за угол и увеличив MAX_REWIND выше задержки стрелка для фиксации нечестного попадания).

А стоит ли перематывать абсолютно всех?

Освоив путешествия во времени, мы сталкиваемся с прозаической производительностью. В матче на 32 человека при каждом выстреле сервер теоретически должен перематывать историю всех участников. Разумнее действовать иначе: зная вектор и траекторию пули, мы сперва отсеиваем объекты, способные пересечься с лучом, и лишь затем обращаемся к архиву. Это позволяет отбросить 90% нерелевантных сущностей и колоссально разгрузить процессор.
SHOT RAY
v
+-----------------------+
| 32 players |
| O O O |
| O |
| O O |
| O |
+-----------------------+
|
spatial filter
v
O O
2.3 avg.
ALL PLAYERS FILTERED
| |
v v
0.9 ms 0.08 ms
Таким образом, мы оптимизируем не столько сам алгоритм ревинда, сколько предварительную фильтрацию кандидатов: если траектория пули проходит в сторонев от всех игроков, трудоемкая логика хитрегистрации даже не запускается.
Испытать самому (отстреливаясь по толпе и сравнивая издержки производительности при тотальной проверке и при использовании фильтра кандидатов).

Здесь вскрывается пласт архитектурных нюансов, доказывающих тесную связь сетевого кода с базовыми механиками. Возьмем простую последовательность операций на тике:
SaveHitboxSnapshot();
UpdateAnimation();
Подобный порядок порождает рассинхрон: визуальная оболочка модели уже приняла новую позу, а сохраненный хитбокс всё еще отражает предыдущую. В неспешных сценариях это незаметно, но при стрельбе по скоростным целям (техника, спринт) погрешность в несколько сантиметров превращает попадание в промах.
Попробовать в демо (поменяв местами вызовы SaveHitboxSnapshot() и UpdateAnimation() для наблюдения за расхождением визуала и хитбоксов).

Сервер должен сохранять бдительность

До сих пор мы старались угодить игроку, однако стоит помнить, что излишняя доверчивость открывает лазейки для эксплойтов. Пинг позволяет клиенту отрапортовать серверу: Я совершил выстрел на тике 123456.
Фактически клиент манипулирует временной шкалой, поэтому хронологический параметр нельзя принимать на веру. Сервер обязан самостоятельно ограничивать глубину отмотки и верифицировать пришедший тик, сверяя его со скользящим средним последних 32 пакетов и отсекая запросы с отклонением свыше 60 миллисекунд. В противном случае полезная механика мгновенно превратится в инструмент автоматической накрутки фрагов. Подозрительные инциденты подлежат обязательной фильтрации и логированию.
Это классическое правило сетевого программирования: как только система доверяет клиенту чуть больше необходимого, тут же находятся энтузиасты, готовые обратить это упущение в свою пользу.
Проверить интерактивно (попытавшись передать ложные данные о задержке и проследив за реакцией валидатора).

А если стрелок уже погиб?

Существует занятный краевой случай: персонаж производит выстрел, пакет устремляется к серверу, но в пути стрелок погибает от вражеского огня. Пакет доходит с опозданием. Как поступить? Если ориентироваться на текущий статус, вердикт звучит однозначно: «Мертвец не стреляет». Но для игрока с высоким пингом это обернется абсурдом, при котором его пули исчезают в момент серверной гибели.
Разумнее оценивать не только актуальное состояние, но и статус бойца на тот момент, когда был совершен выстрел. В итоге мы получаем парадоксальную, но справедливую ситуацию, где сервер выносит двойной вердикт:
Да, в эту секунду ты мертв. И да, ты успел ликвидировать цель,
потому что в момент нажатия на спуск был еще жив.

Проверить самому (отправляя пулю за мгновение до смерти и сравнивая наивную и честную проверки статуса alive).
Компенсацию лагов нельзя рассматривать в отрыве от предикции клиента (client prediction), поскольку обе концепции решают единую проблему с разных флангов. Клиент должен бесперебойно симулировать жизнь, пока сервер находится далеко, а впоследствии обе версии мира обязаны прийти к консенсусу, опираясь на общую временную шкалу.
Здесь часто путают lag compensation и чистый rollback (откат симуляции): термины «откатить» и «вернуться в прошлое» звучат похоже, но под капотом скрываются принципиально разные архитектуры.
Роллбэк возвращает весь мир в прошлое и проигрывает симуляцию заново для получения нового состояния.
LAG COMPENSATION
PAST NOW
| |
v v
[old hitboxes] -------------------- [world]
|
+--> raycast
|
+--> restore
ROLLBACK
PAST NOW
| |
v v
[whole world] ---- replay ----------> [world]
|
+--> physics
+--> AI
+--> projectiles
+--> inputs
Компенсация задержки устроена изящнее: мы не трогаем физику окружения, не пересчитываем ИИ и не симулируем заново всех 32 участников. Мы точечно подтягиваем исторические координаты хитбоксов, фиксируем попадание и возвращаем геометрию на место.
Таким образом, фраза «мы возвращаем игру в прошлое» маскирует две разные инженерные концепции: одна оперирует килобайтами архивных хитбоксов, а вторая принудительно пересчитывает львиную долю игрового процесса.
Попробовать в демо (сравнивая точечный ревинд хитбокса и переигровку всей симуляции мира).

Игры обязаны лукавить со временем
Если вдуматься, вся система lag compensation выглядит сюрреалистично. Мы намеренно храним архив прошлого, принудительно отматываем реальность назад и проверяем коллизии не в актуальном серверном состоянии, а в той конфигурации, которую клиент наблюдал мгновение назад. И всё это ради одной цели — выдать результат, кажущийся пользователю органичным.
Сервер сознательно рассчитывает события для несуществующей реальности. Это близкий родственник coyote time: с позиции сухой физики звучит безумно, но интерактивные развлечения не обязаны быть симуляторами законов Ньютона.
Обе эти технологии служат одной задаче — подстроиться под психофизиологию человека. Геймеру совершенно необязательно знать, что его клиент существует со смещением в 100 миллисекунд, что пакеты приходят с задержкой, сервер архивирует снапшоты, а хитбоксы подвергаются ревинду. Этому ему знать и не нужно.
В конечном счете любой многопользовательский проект — это персональный театр для каждого зрителя. Хорошая игра имеет полное право немного лукавить, если в итоге в нее по-настоящему интересно играть.

P. S. Выражаю благодарность Клавдию и Фубле за неоценимую помощь в подготовке интерактивных примеров — заставлять вас компилировать C++ код на локальной машине было бы перебором. Ссылки на демки из предыдущей статьи также прикреплены на случай, если вы пропустили.
Клавдий и Фубля
Маленькая девочка собирается с мамой на прогулку, мама ее поторапливает:
— Собирайся быстрее, дочка.
— Сейчас, мамочка, только Фублю возьму!
— Кого???
Девочка убегает в детскую и возвращается с ужасной, замызганной, омерзительной куклой с продавленым глазом, облезлыми волосами и погрызенной ногой и в каких‑то тряпках вместо платья.
Мама, видя это чудовище, в ужасе:
— Фу! Бл*!
Девочка:
— Вот и папа её так же называет
Prime 1 Studio открыла предзаказ на статую Гатса по мотивам альтернативной обложки 34-го тома манги Берсерк
Фильм «Топ Ган: Мэверик» возглавил мировой чарт Paramount+ на фоне выхода Ace Combat 8
Новая функция Xbox не поддерживает игры серии Forza
Игроки раскритиковали графику новой версии Hell Let Loose, сравнив её с проектами для PlayStation 1
Разработчики Tomb Raider Legacy of Atlantis, судя по всему, отказались от использования Denuvo
Новый выпуск видеоновостей SE7EN.ws посвящен геймерам-паразитам, катастрофе в GeoW E-Day, исправлениям в «Ведьмак 3», музыке из HOMM 3 Remake и шлепкам в GTA 6
Для Android вышел фанатский порт первой части Hades
В Германии планируют ввести специальный тарифный план на электроэнергию для геймеров