![image](https://developer.valvesoftware.com/w/images/c/ca/Lag_compensation.jpg)
TL;DR
Я создал демо, показывающее, как реализовать прогнозирование на стороне клиента физического движения игрока в Unity — GitHub.
Введение
В начале 2012 года я написал пост о как-бы-реализации прогнозирования на стороне клиента физического движения игрока в Unity. Благодаря Physics.Simulate() тот неуклюжий обходной способ, который я описал, больше не нужен. Старый пост до сих пор является одним из самых популярных в моём блоге, но для современного Unity эта информация уже неверна. Поэтому я выпускаю версию 2018 года.
Что-что на стороне клиента?
В соревновательных многопользовательских играх необходимо по возможности избегать читерства. Обычно это значит, что применяется сетевая модель с авторитарным сервером: клиенты отправляют серверу вводимую информацию, а сервер превращает эту информацию в перемещение игрока, а потом отправляет снэпшот получившегося состояния игрока обратно клиенту. При этом возникает задержка между нажатием клавиши и отображением результатом, что неприемлемо для любых активных игр. Прогнозирование на стороне клиента — это очень популярная техника, скрывающая задержку, прогнозируя то, каким будет получившееся движение и сразу показывая его игроку. Когда клиент получает результаты от сервера, он сравнивает их с тем, что спрогнозировал клиент, и если они отличаются, то прогноз был ошибочным и нуждается в коррекции состояния.
Получаемые от сервера снэпшоты всегда прибывают из прошлого относительно прогнозируемого состояния клиента (например, если передача данных от клиента серверу и обратно занимает 150 мс, то каждый снэпшот будет запаздывать не менее чем на 150 мс). В результате этого, когда клиенту нужно скорректировать неправильный прогноз, он должен откатиться к этой точке в прошлом, а затем воспроизвести всю введённую в промежутке информацию, чтобы вернуться к тому, где он находится. Если движение игрока в игре основано на физике, то необходим Physics.Simulate() для симуляции в одном кадре нескольких циклов. Если при движении игрока используются только Character Controllers (или capsule cast и т.д.), тогда можно обойтись без Physics.Simulate() — и я предполагаю, что производительность будет лучше.
Я воспользуюсь Unity, чтобы воссоздать сетевое демо под названием «Zen of Networked Physics» Гленна Фидлера, которое мне уже давно нравится. У игрока есть физический куб, к которому он может прикладывать силы, толкая его в сцене. Демо симулирует различные условия сети, в числе задержку и утерю пакетов.
Приступаем к работе
Первое, что нужно сделать — отключить автоматическую симуляцию физики. Хотя Physics.Simulate() позволяет нам сообщить физической системе, когда начинать симуляцию, по умолчанию она выполняет симуляцию автоматически на основании фиксированной дельты времени проекта. Поэтому мы отключим её в Edit->Project Settings->Physics, сняв флажок "Auto Simulation".
Для начала мы создадим простую однопользовательскую реализацию. Ввод сэмплируется (w, a, s, d для перемещения и пробел для прыжка), и всё сводится к простым силам, прикладываемым к Rigidbody с помощью AddForce().
public class Logic : MonoBehaviour
{
public GameObject player;
private float timer;
private void Start()
{
this.timer = 0.0f;
}
private void Update()
{
this.timer += Time.deltaTime;
while (this.timer >= Time.fixedDeltaTime)
{
this.timer -= Time.fixedDeltaTime;
Inputs inputs;
inputs.up = Input.GetKey(KeyCode.W);
inputs.down = Input.GetKey(KeyCode.S);
inputs.left = Input.GetKey(KeyCode.A);
inputs.right = Input.GetKey(KeyCode.D);
inputs.jump = Input.GetKey(KeyCode.Space);
this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs);
Physics.Simulate(Time.fixedDeltaTime);
}
}
}
Перемещение игрока, пока сеть не используется
Отправка ввода на сервер
Теперь нам нужно отправить ввод на сервер, который тоже выполнит этот код движения, сделает снэпшот состояния куба и отправит его обратно клиенту.
// client
private void Update()
{
this.timer += Time.deltaTime;
while (this.timer >= Time.fixedDeltaTime)
{
this.timer -= Time.fixedDeltaTime;
Inputs inputs = this.SampleInputs();
InputMessage input_msg;
input_msg.inputs = inputs;
input_msg.tick_number = this.tick_number;
this.SendToServer(input_msg);
this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs);
Physics.Simulate(Time.fixedDeltaTime);
++this.tick_number;
}
}
Здесь пока ничего особенного, единственное, на что я хочу обратить внимание — добавление переменной tick_number. Она нужна, чтобы когда сервер отправляет снэпшоты состояния куба обратно клиенту, мы могли узнать, какому такту клиента соответствует это состояние, чтобы можно было сравнить это состояние с предсказанным клиентом (что мы добавим чуть позже).
// server
private void Update()
{
while (this.HasAvailableInputMessages())
{
InputMessage input_msg = this.GetInputMessage();
Rigidbody rigidbody = player.GetComponent<Rigidbody>();
this.AddForcesToPlayer(rigidbody, input_msg.inputs);
Physics.Simulate(Time.fixedDeltaTime);
StateMessage state_msg;
state_msg.position = rigidbody.position;
state_msg.rotation = rigidbody.rotation;
state_msg.velocity = rigidbody.velocity;
state_msg.angular_velocity = rigidbody.angularVelocity;
state_msg.tick_number = input_msg.tick_number + 1;
this.SendToClient(state_msg);
}
}
Всё просто — сервер ждёт сообщений ввода, когда он его получает, то симулирует такт. Затем он берёт снэпшот получившегося состояния куба и отправляет его обратно клиенту. Можно заметить, что tick_number в сообщении состояния на единицу больше, чем tick_number в сообщении ввода. Так сделано, потому что лично мне интуитивно более удобно думать о «состоянии игрока в такт 100» как о «состоянии игрока в начале такта 100». Поэтому состояние игрока в такте 100 в сочетании с вводом игрока в такте 100 создают новое состояние для игрока в такте 101.
Staten + Inputn = Staten+1
Я не говорю, что вы должны воспринимать это так же, главное — постоянство подхода.
Также нужно сказать, что я не отправляю эти сообщения через реальный сокет, а имитирую его, записывая их в очереди, симулируя задержку и утерю пакетов. Сцена содержит два физических куба — один для клиента, другой для сервера. При обновлении клиентского куба я отключаю GameObject серверного куба, и наоборот.
Однако я не симулирую сетевой дребезг и доставку пакетов в неправильном порядке, и именно поэтому делаю допущение, что каждое получаемое сообщение ввода новее предыдущего. Эта имитация нужна для того, чтобы очень просто выполнять «клиент» и «сервер» в одном инстансе Unity, чтобы мы могли скомбинировать кубы сервера и клиента в одной сцене.
Также можно заметить, что если сообщение ввода сбрасывается и не достигает сервера, то сервер симулирует меньшее количество тактов, чем клиент, а потому будет создавать другое состояние. Это правда, но даже если бы мы симулировали эти пропуски, то ввод всё равно мог быть неверным, что тоже привело бы к другому состоянию. С этой проблемой мы разберёмся позже.
Надо также добавить, что в этом примере есть только один клиент, что упрощает работу. Если бы у нас было несколько клиентов, то нам понадобилось бы а) при вызове Physics.Simulate() проверять, что на сервере включен куб только одного игрока или б) если сервер получил ввод от нескольких кубов, симулировать их все вместе.
Задержка 75 мс (150 мс в обе стороны)
0% утерянных пакетов
Жёлтый куб — серверный игрок
Синий куб — последний снэпшот, полученный клиентом
Всё пока выглядит хорошо, но я был немного избирательным с тем, что я фиксировал на видео, чтобы скрыть довольно серьёзную проблему.
Сбой детерминированности
Посмотрите теперь на это:
Ой-ёй...
Это видео было записано без утерь пакетов, тем не менее, симуляции всё равно разнятся при совершенно одинаковом вводе. Я не совсем понимаю, почему так происходит — PhysX должен быть достаточно детерминированным, поэтому я нахожу поразительным то, что симуляции так часто расходятся. Это может быть связано с тем, что я постоянно включаю и отключаю GameObject кубов, то есть возможно, что проблема уменьшится при использовании двух разных инстансов Unity. Это может быть и багом, если вы увидите его в коде на GitHub, то дайте мне знать.
Как бы то ни было, неверные прогнозы — это неотъемлемый факт в прогнозировании на стороне клиента, поэтому давайте с ними справляться.
Можно сделать перемотку?
Процесс достаточно прост — когда клиент прогнозирует движение, он сохраняет буфер состояния (позицию и поворот) и ввод. После получения сообщения состояния от сервера он сравнивает полученное состояние с прогнозируемым состоянием из буфера. Если они отличаются на слишком большую величину, то мы переопределяем состояние клиентского куба в прошлом, а затем симулируем заново все промежуточные такты.
// client
private ClientState[] client_state_buffer = new ClientState[1024];
private Inputs[] client_input_buffer = new Inputs[1024];
private void Update()
{
this.timer += Time.deltaTime;
while (this.timer >= Time.fixedDeltaTime)
{
this.timer -= Time.fixedDeltaTime;
Inputs inputs = this.SampleInputs();
InputMessage input_msg;
input_msg.inputs = inputs;
input_msg.tick_number = this.tick_number;
this.SendToServer(input_msg);
uint buffer_slot = this.tick_number % 1024;
this.client_input_buffer[buffer_slot] = inputs;
this.client_state_buffer[buffer_slot].position = rigidbody.position;
this.client_state_buffer[buffer_slot].rotation = rigidbody.rotation;
this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs);
Physics.Simulate(Time.fixedDeltaTime);
++this.tick_number;
}
while (this.HasAvailableStateMessage())
{
StateMessage state_msg = this.GetStateMessage();
uint buffer_slot = state_msg.tick_number % c_client_buffer_size;
Vector3 position_error = state_msg.position - this.client_state_buffer[buffer_slot].position;
if (position_error.sqrMagnitude > 0.0000001f)
{
// rewind & replay
Rigidbody player_rigidbody = player.GetComponent<Rigidbody>();
player_rigidbody.position = state_msg.position;
player_rigidbody.rotation = state_msg.rotation;
player_rigidbody.velocity = state_msg.velocity;
player_rigidbody.angularVelocity = state_msg.angular_velocity;
uint rewind_tick_number = state_msg.tick_number;
while (rewind_tick_number < this.tick_number)
{
buffer_slot = rewind_tick_number % c_client_buffer_size;
this.client_input_buffer[buffer_slot] = inputs;
this.client_state_buffer[buffer_slot].position = player_rigidbody.position;
this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation;
this.AddForcesToPlayer(player_rigidbody, inputs);
Physics.Simulate(Time.fixedDeltaTime);
++rewind_tick_number;
}
}
}
}
Буферизированные данные ввода и состояние хранятся в очень простом циклическом буфере, где в качестве индекса используется идентификатор такта. И выбрал для тактовой частоты физики значение 64 Гц, то есть буфер на 1024 элементов даёт нам место на 16 секунд, и это значительно больше того, что нам может понадобиться.
Коррекция включена!
Передача избыточного ввода
Сообщения ввода обычно очень малы — нажатые кнопки можно скомбинировать в битовое поле, которое занимает всего несколько байтов. В нашем сообщении ещё есть номер такта, занимающий 4 байта, но мы можем запросто сжать их, воспользовавшись 8-битным значением с переносом (возможно, интервала 0-255 будет слишком мало, мы можем подстраховаться и увеличить его до 9 или 10 бит). Как бы то ни было, эти сообщения имеют достаточно малый размер, и это значит, что мы можем отправлять в каждом сообщении множество данных ввода (на тот случай, если предыдущие данные ввода были утеряны). Как далеко назад нам стоит возвращаться? Ну, клиент знает номер такта последнего сообщения состояния, которое он получил от сервера, поэтому нет смысла возвращаться дальше, чем этот такт. Также нам нужно наложить ограничение на количество избыточных данных ввода, которые отправляются клиентом. В своём демо я этого не делал, но это стоит реализовать в готовом коде.
while (this.HasAvailableStateMessage())
{
StateMessage state_msg = this.GetStateMessage();
this.client_last_received_state_tick = state_msg.tick_number;
Это простое изменение, клиент просто записывает номер такта последнего полученного сообщения состояния.
Inputs inputs = this.SampleInputs();
InputMessage input_msg;
input_msg.start_tick_number = this.client_last_received_state_tick;
input_msg.inputs = new List<Inputs>();
for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick)
{
input_msg.inputs.Add(this.client_input_buffer[tick % 1024]);
}
this.SendToServer(input_msg);
Сообщение ввода, отправляемое клиентом, теперь содержит список данных ввода, а не только один элемент. Часть с номером такта получает новое значение — теперь это номер такта первого ввода в этом списке.
while (this.HasAvailableInputMessages())
{
InputMessage input_msg = this.GetInputMessage();
// message contains an array of inputs, calculate what tick the final one is
uint max_tick = input_msg.start_tick_number + (uint)input_msg.inputs.Count - 1;
// if that tick is greater than or equal to the current tick we're on, then it
// has inputs which are new
if (max_tick >= server_tick_number)
{
// there may be some inputs in the array that we've already had,
// so figure out where to start
uint start_i = server_tick_number > input_msg.start_tick_number ? (server_tick_number - input_msg.start_tick_number) : 0;
// run through all relevant inputs, and step player forward
Rigidbody rigidbody = player.GetComponent<Rigidbody>();
for (int i = (int)start_i; i < input_msg.inputs.Count; ++i)
{
this.AddForcesToPlayer(rigidbody, input_msg.inputs[i]);
Physics.Simulate(Time.fixedDeltaTime);
}
server_tick_number = max_tick + 1;
}
}
Когда сервер получает сообщение ввода, он знает номер такта первого ввода и количество данных ввода в сообщении. Поэтому он может вычислить такт последнего ввода в сообщении. Если этот последний такт больше или равен номеру такта сервера, то он знает, что сообщение содержит хотя бы один ввод, который сервер ещё не видел. Если это так, то он симулирует все новые данные ввода.
Вы могли заметить, что если мы ограничим количество избыточных данных ввода в сообщении ввода, то при достаточно большом количестве утерянных сообщений ввода у нас появится пробел симуляции между сервером и клиентом. То есть сервер может симулировать такт 100, отправить сообщение состояния для начала такта 101, а потом получить сообщение ввода, начинающееся с такта 105. В показанном выше коде сервер перейдёт к 105, он не попытается симулировать промежуточные такты на основе последних известных данных ввода. Нужно ли вам это — зависит от вашего решения и от того, какой должна быть игра. Лично я не стал бы заставлять сервер строить догадки и перемещать игрока по карте из-за плохого состояния сети. Я считаю, что лучше оставить игрока на месте, пока соединение не восстановится.
В демо «Zen of Networked Physics» есть функция отправки клиентом «важных ходов», то есть он отправляет избыточные данные ввода только тогда, когда они отличаются от ввода, переданного ранее. Это можно назвать дельта-сжатием ввода, и с помощью него ещё больше уменьшить размер сообщений ввода. Но пока я этого не делал, потому что в этом демо нет никакой оптимизации загрузки сети.
Перед отправкой избыточных данных ввода: при потере 25% пакетов движение куба медленное и дёрганное, его продолжает отбрасывать назад.
После отправки избыточных данных ввода: при потере 25% пакетов по-прежнему есть дёрганная коррекция, но кубы движутся с приемлемой скоростью.
Изменяющаяся частота снэпшотов
В этом демо частота, с которой сервер отправляет клиенту снэпшоты, варьируется. При сниженной частоте клиенту понадобится больше времени на получение коррекции от сервера. Поэтому когда клиент ошибается в прогнозе, то до получения сообщения состояния он может отклониться ещё сильнее, что приведёт к более заметной коррекции. При высокой частоте снэпшотов утеря пакетов гораздо менее важна, поэтому клиенту не приходится ждать долго получения следующего снэпшота.
Частота снэпшотов 64 Гц
Частота снэпшотов 16 Гц
Частота снэпшотов 2 Гц
Очевидно, что чем выше частота снэпшотов, тем лучше, поэтому стоит отправлять их как можно чаще. Но это ещё зависит от объёма дополнительного трафика, его стоимости, наличия у вас выделенных серверов, вычислительных затрат серверов, и так далее.
Сглаживание коррекции
Мы создаём неверные прогнозы и получаем дёрганные коррекции чаще, чем хотелось бы. Не имея должного доступа к интеграции Unity/PhysX, я почти никак не могу отладить эти ошибочные прогнозы. Я говорил это раньше, но повторю ещё раз — если вы найдёте что-то связанное с физикой, в чём я ошибаюсь, то дайте мне знать об этом.
Я обошёл решение этой проблемы, замазав трещины старым добрым сглаживанием! Когда происходит коррекция, клиент просто сглаживает в течение нескольких кадров позицию и поворот игрока по направлению к правильному состоянию. Сам физический куб корректируется мгновенно (он невидим), но у нас есть второй куб только для отображения, который позволяет выполнять сглаживание.
Vector3 position_error = state_msg.position - predicted_state.position;
float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation);
if (position_error.sqrMagnitude > 0.0000001f ||
rotation_error > 0.00001f)
{
Rigidbody player_rigidbody = player.GetComponent<Rigidbody>();
// capture the current predicted pos for smoothing
Vector3 prev_pos = player_rigidbody.position + this.client_pos_error;
Quaternion prev_rot = player_rigidbody.rotation * this.client_rot_error;
// rewind & replay
player_rigidbody.position = state_msg.position;
player_rigidbody.rotation = state_msg.rotation;
player_rigidbody.velocity = state_msg.velocity;
player_rigidbody.angularVelocity = state_msg.angular_velocity;
uint rewind_tick_number = state_msg.tick_number;
while (rewind_tick_number < this.tick_number)
{
buffer_slot = rewind_tick_number % c_client_buffer_size;
this.client_input_buffer[buffer_slot] = inputs;
this.client_state_buffer[buffer_slot].position = player_rigidbody.position;
this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation;
this.AddForcesToPlayer(player_rigidbody, inputs);
Physics.Simulate(Time.fixedDeltaTime);
++rewind_tick_number;
}
// if more than 2ms apart, just snap
if ((prev_pos - player_rigidbody.position).sqrMagnitude >= 4.0f)
{
this.client_pos_error = Vector3.zero;
this.client_rot_error = Quaternion.identity;
}
else
{
this.client_pos_error = prev_pos - player_rigidbody.position;
this.client_rot_error = Quaternion.Inverse(player_rigidbody.rotation) * prev_rot;
}
}
Когда возникает ошибочный прогноз, клиент отслеживает разность позиции/поворота после коррекции. Если общее расстояние коррекции позиции больше 2 метров, то куб просто перемещается рывком — сглаживание всё равно выглядело бы плохо, так пусть он хотя бы как можно быстрее вернётся к верному состоянию.
this.client_pos_error *= 0.9f;
this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f);
this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error;
this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error;
В каждом кадре клиент выполняет lerp/slerp по направлению к правильной позиции/повороту на 10%, это стандартный степенной подход к усреднению движения. Он зависит от частоты кадров, но для целей нашего демо этого вполне достаточно.
Задержка 250 мс
Утеря 10% пакетов
Без сглаживания коррекция очень заметна
Задержка 250 мс
Утеря 10% пакетов
Со сглаживанием коррекцию заметить гораздо сложнее
Конечный результат работает довольно хорошо, я хочу создать его версию, которая действительно будет отправлять пакеты, а не имитировать их. Но, по крайней мере, это proof of concept системы прогнозирования на стороне клиента с настоящими физическими объектами в Unity без необходимости использования физических плагинов и тому подобного.