Netcode: предсказание, реконсиляция, лаг-компенсация
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Netcode: предсказание, реконсиляция, лаг-компенсация
Сервер у нас авторитетный: правду считает он. Но между клиентом и сервером лежит задержка в десятки миллисекунд. Если клиент будет ждать ответа сервера на каждое нажатие, управление станет ватным, как будто играешь по почте. Сегодня самое интересное в сетевых играх: как сделать так, чтобы при живой задержке игра ощущалась мгновенной и при этом оставалась честной. Разберём четыре механизма, каждый под свою задачу: предсказание (свой игрок реагирует сразу), реконсиляция (мягко поправляем предсказание правдой сервера), интерполяция (чужих рисуем плавно) и лаг-компенсация (выстрел засчитывается по тому, что видел стрелок). А в конце потрогаешь всё это вживую: виджет ниже это наш netcode, скомпилированный в WebAssembly.
Корень проблемы: задержка
Назовём вещи именами. Пакет летит до сервера и обратно не мгновенно, это и есть RTT, и он измеряется десятками миллисекунд. Наивный клиент, который рисует своего игрока только по снапшотам сервера, отставал бы на половину RTT при каждом шаге. Поэтому клиент не ждёт. Он применяет три приёма для себя и чужих и сообщает серверу одну хитрую деталь для честных выстрелов. Идём по порядку.
Предсказание: свой игрок едет сразу
Client-side prediction это просто: нажал, сразу поехал. Клиент применяет свой ввод к себе тем же кодом симуляции, что крутит сервер, и не ждёт ничьего разрешения.
pub fn push_input(&mut self, buttons: u8, aim: u8) -> Input {
self.next_seq += 1;
let view_tick = self.latest_tick.saturating_sub(INTERP_DELAY_TICKS as u32);
let input = Input::new(self.next_seq, buttons).with_aim(aim).with_view(view_tick);
self.predicted.apply(input); // применяем к себе немедленно
self.pending.push_back(input); // и запоминаем как неподтверждённый
input
}
Два важных момента. Первый: каждый ввод получает растущий номер seq и кладётся в очередь pending неподтверждённых, к этой очереди мы вернёмся в реконсиляции. Второй: в ввод вшивается view_tick, тот такт сервера, который клиент сейчас видит. Это семечко лаг-компенсации, пока просто запомни, что оно тут появляется.
То, что предсказание работает, держится на одном свойстве из урока про сервер: apply это общий код клиента и сервера, и он детерминирован.
/// Применяет один ввод за один такт: движение и обновление прицела. Это общий
/// код сервера и клиента. Мёртвый игрок не двигается.
pub fn apply(&mut self, input: Input) {
self.aim = input.aim;
if !self.alive { return; }
let (dx, dy) = input.axis();
let len = (dx * dx + dy * dy).sqrt();
if len > 0.0 {
let step = MOVE_SPEED * TICK_DT;
self.x += dx / len * step;
self.y += dy / len * step;
}
self.x = self.x.clamp(0.0, WORLD_SIZE);
self.y = self.y.clamp(0.0, WORLD_SIZE);
}
Раз и клиент, и сервер считают движение этой же функцией, предсказание клиента почти всегда совпадает с тем, что потом посчитает сервер. Верное предсказание невидимо: ты просто играешь, а сеть незаметна.
Реконсиляция: поправка без рывка
«Почти всегда» это не «всегда». Иногда предсказание расходится с правдой (столкнулся с тем, кого ещё не видел, потерялся пакет ввода). Тогда нужна реконсиляция. Сервер в каждом снапшоте сообщает acked_input, номер последнего ввода клиента, который он уже учёл. Клиент по нему откатывается к правде и проигрывает заново всё, что ещё не подтверждено:
fn reconcile(&mut self, authoritative: PlayerState, acked_input: u32) {
let before = self.predicted;
// Выкидываем подтверждённые вводы: сервер их уже учёл в authoritative.
while let Some(front) = self.pending.front() {
if front.seq <= acked_input { self.pending.pop_front(); } else { break; }
}
// Откатываемся к правде и проигрываем заново всё неподтверждённое.
let mut state = authoritative;
for input in &self.pending {
state.apply(*input);
}
self.predicted = state;
// Насколько предсказание разошлось с правдой (видимый рывок при ошибке).
let dx = self.predicted.x - before.x;
let dy = self.predicted.y - before.y;
self.last_correction = (dx * dx + dy * dy).sqrt();
}
Вот почему pending так важна. Сервер прислал состояние на момент acked_input, но клиент с тех пор успел нажать ещё несколько раз. Если просто показать авторитетное состояние, игрок прыгнет в прошлое. Поэтому клиент берёт правду как базу и накатывает поверх неё свои неподтверждённые вводы тем же apply. Предсказание было верным, результат совпадёт с тем, что на экране, и last_correction будет около нуля. Ошиблось, картинка мягко доедет до правды.
Интерполяция: чужих рисуем в прошлом
Своего игрока мы предсказываем. А чужих предсказать нельзя: их ввод нам неизвестен. Зато их состояние периодически приходит в снапшотах. Между двумя снапшотами зиял бы провал, поэтому чужих рисуем не «сейчас», а в прошлом, плавно двигая между двумя последними известными позициями. Это интерполяция сущностей: маленькая задержка отрисовки в обмен на плавность.
/// На сколько тактов назад рисуем чужих. 6 тактов это 100 мс при 60 Гц:
/// запас, чтобы между двумя снапшотами всегда было что интерполировать.
const INTERP_DELAY_TICKS: f32 = 6.0;
fn sample(&self, render_tick: f32) -> Option<PlayerState> {
// Ищем пару соседних снапшотов (a, b) так, что a.tick <= render_tick <= b.tick,
// и линейно смешиваем их по доле t между ними.
for window in 0..self.history.len() - 1 {
let (ta, sa) = self.history[window];
let (tb, sb) = self.history[window + 1];
if (render_tick >= ta as f32) && (render_tick <= tb as f32) {
let t = (render_tick - ta as f32) / (tb - ta) as f32;
return Some(PlayerState::lerp(&sa, &sb, t));
}
}
// ... если render_tick убежал за буфер, аккуратно экстраполируем по двум последним
}
Задержка интерполяции это компромисс: больше задержка, устойчивее к джиттеру, но чужие сильнее «в прошлом». Шесть тактов (около 100 мс) дают запас, чтобы даже при неравномерном приходе снапшотов всегда было что между чем смешивать. Если данных впереди не хватило, sample аккуратно экстраполирует по двум последним, с ограничением, чтобы при долгой потере чужой не улетел в бесконечность.
Лаг-компенсация: честный выстрел
Теперь самое тонкое, и тут всё связывается. Сложи две вещи: своего игрока ты видишь в настоящем (предсказание), а чужих в прошлом (интерполяция). Стреляешь точно в голову врага на экране, но пока выстрел летел до сервера, враг по серверным часам уже убежал. Сервер, проверяя попадание по текущим позициям, засчитал бы промах. Играть так невозможно.
Решение это лаг-компенсация: сервер хранит историю состояний мира и при выстреле откатывает чужих игроков ровно к тому такту, который видел стрелок. Помнишь view_tick, вшитый в ввод при предсказании? Вот ради чего он ехал:
// Откат: позиции чужих игроков на том такте, который видел стрелок.
let targets = self.rewound_targets(input.view_tick);
let hit = World::raycast_hit(&shooter, &targets);
/// Позиции игроков на такте view_tick (то, что видел стрелок). Если такта нет в
/// истории, берём текущее состояние.
fn rewound_targets(&self, view_tick: u32) -> BTreeMap<PlayerId, PlayerState> {
for past in self.history.iter() {
if past.tick == view_tick {
return past.players.clone();
}
}
self.world.players.clone()
}
Стрелок остаётся в настоящем, а цели сервер отматывает в прошлое, к тому, что было у стрелка на экране. Геометрия попадания (raycast_hit, ray_circle) это чистые функции, поэтому сервер зовёт их и на «сейчас», и на откате одинаково. У приёма есть оборотная сторона: цель может получить урон, уже забежав за угол по своим часам, ведь по часам стрелка она была на виду. Это сознательный размен, и почти все шутеры выбирают «в пользу стрелка»: важнее, чтобы попадание совпадало с прицелом, чем чтобы оно совпадало с самым свежим положением цели.
Потрогай вживую
Ниже весь этот netcode целиком, скомпилированный из нашего Rust (examples/our-netgame) в WebAssembly. Слева правда сервера, справа мир глазами клиента: своего игрока он предсказывает, чужих интерполирует. Покрути ползунки канала (задержку, потери, джиттер) и посмотри, как растёт RTT, как поправки реконсиляции дёргают своего игрока при ошибке предсказания и как чужие уезжают в прошлое при джиттере. Это тот же код, что крутит сервер и тесты, и тот же, что лежал бы в проде, одна симуляция на две поверхности.
То, что один и тот же Rust обслуживает и боевой сервер, и браузерный виджет урока, не случайность: это приём из блока про WASM, который мы разберём дальше.
Что унести из урока
Весь netcode прячет одну вещь: RTT. Предсказание применяет свой ввод немедленно тем же детерминированным apply, что и сервер, поэтому управление мгновенно, а верное предсказание невидимо. Реконсиляция чинит расхождения без рывка: клиент держит очередь неподтверждённых вводов pending, по acked_input откатывается к авторитетному состоянию и заново проигрывает поверх него всё неподтверждённое. Интерполяция рисует чужих в прошлом, плавно смешивая два соседних снапшота, ценой небольшой задержки отрисовки. Лаг-компенсация делает выстрел честным: сервер откатывает цели к view_tick, который видел стрелок, и проверяет попадание там, размен «в пользу стрелка». Связующая нить это view_tick, который рождается в предсказании и срабатывает в откате на сервере, и общая детерминированная симуляция, без которой ни предсказание, ни откат не были бы воспроизводимы.
Остался последний слой проекта: голый UDP всё ещё теряет и дублирует пакеты. Дальше построим свою надёжность поверх UDP: нумерацию, acks, переотправку важного и контроль перегрузки, и соберём играбельное демо целиком.