Всем привет! Меня зовут Гриша Дядиченко, я технический директор и основатель White Label Games. Уже больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном это заказная разработка, плюс собственные прототипы по вечерам, до которых дотягиваются руки.

Делал я как-то на работе, по вечерам в свободное время, VR-шутер. Стрельбу, понятное дело, заложил себе на выходные: ну а что, raycast из ствола, событие попадания, отнял здоровье — делов-то. К вечеру воскресенья оно даже работало. Только ощущалось так, будто тыкаешь противника палкой: ни веса, ни отдачи, ни чувства, что ты вообще попал. Знакомо, наверное, каждому, кто хоть раз ставил в сцену оружие и жал «выстрел» — механически всё верно, а стрельба вялая и какая-то ненастоящая. Половина лечения тут — чистая полировка: вспышки, звук, тряска камеры, импакт-эффекты. А вот вторая половина — невидимая математика под капотом: та, что решает, ощущается стрельба честной и отзывчивой или кривой и несправедливой. Спред, который мозг считывает как «нечестный». Отдача, которую можно выучить. Попадание, которое по сети то засчитывается, то нет. Вот это всё и разберём.

Сталкивались ли вы с ситуацией, когда в шутере вы точно попали по противнику, а сервер сказал «промах»? Или с тем, что AI-противник стреляет в вас сверхскоростным снарядом и ни разу не попадает в движущуюся цель? Или с тем, что AK-47 в Counter-Strike рисует «семёрку» из пуль вверх и влево — и это, конечно же, никакой не баг, а вполне продуманная механика? Под капотом у всех этих ситуаций — конкретная математика.

Чтож, давайте по порядку. Чем hitscan отличается от projectile и какой хвост последствий тянется за выбором; с какой геометрией на самом деле проверяют попадание — лучи, капсулы и почему хитбоксы это не полигональный меш; как сделать честный спред пули и почему наивный random даёт квадрат вместо круга; откуда берутся «выученные» recoil-паттерны и почему AK рисует семёрку; как AI-снайпер вычисляет упреждение через школьное квадратное уравнение; и наконец, что такое lag compensation, зачем сервер откатывает время на сотню миллисекунд назад и откуда берётся эффект «убит из-за угла». Шесть разделов, в каждом — код на C#. Сразу оговорюсь: я сознательно не лезу в баллистику снайперок с ветром, в проникающие выстрелы через стены и в физику отдачи на уровне «как трясётся ствол» — иначе статья превратится в книжку.

1. Hitscan против projectile: а в чём вообще разница

Итак, вы поставили в сцену оружие и нажали кнопку «выстрел». Что должно произойти? У вас всего два ответа, и они реализованы принципиально по-разному.

Hitscan — вы пускаете луч из ствола, в тот же кадр проверяете пересечение с миром, регистрируете попадание. Время полёта пули — ноль. Так стреляют все винтовки в Counter-Strike, Valorant, Call of Duty, и в Overwatch — Soldier 76, Widowmaker.

Projectile — вы создаёте объект-снаряд, даёте ему скорость, и каждый тик симулируете его движение, проверяя коллизии. Так стреляют ракеты в Quake и Unreal Tournament, плазма в Halo, снайперки в Apex Legends, и вообще любое оружие в Splatoon.

На C# в Unity-стиле hitscan выглядит примерно так:

void FireHitscan() {
    Ray ray = new Ray(muzzle.position, muzzle.forward);
    if (Physics.Raycast(ray, out RaycastHit hit, maxRange)) {
        ApplyDamage(hit.collider, damage);
        SpawnImpactEffect(hit.point, hit.normal);
    }
}

А projectile так:

void FireProjectile() {
    var bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
    bullet.GetComponent<Rigidbody>().linearVelocity = muzzle.forward * bulletSpeed;
}

// внутри пули — OnTriggerEnter / RaycastNonAlloc по тонкому ray-cast'у каждый
// FixedUpdate, чтобы пуля не «прошла сквозь» цель на больших скоростях.

Ключевая разница видна сразу. В hitscan-версии мы вообще не храним пулю как объект — это сразу Raycast и сразу результат. В projectile у нас живёт Rigidbody, который ест CPU каждый тик, плюс проверка на «прошивку» через тонкий ray-cast (про неё — в части 2, иначе быстрая пуля просто перепрыгнет цель между кадрами).

Зачем вообще выбор, если hitscan дешевле? Hitscan по сути — компромисс в пользу простоты и сетевой нагрузки. Серверу нужно меньше пересылать, клиенту — меньше симулировать, попадание ощущается мгновенным. Для тактических шутеров это важно: вы целились в голову, нажали — попадание должно быть бескомпромиссным. На длинных дистанциях projectile-снайперке надо реально «доехать» до цели, и игрок читает задержку как «оружие медленное и несправедливое».

Projectile, наоборот, открывает игроку возможность увернуться. В Quake вы можете шагнуть в сторону, увидев летящую ракету. В CS вы не можете «отойти от пули» — она уже попала в момент выстрела. Это совсем другое игровое ощущение, и именно ради него projectile и берут.

Гибриды встречаются часто. В Halo battle rifle — hitscan, brute shot — projectile. В Apex R301 — hitscan, Kraber — projectile. В Doom Eternal плазма ведёт себя как hitscan-импульсы, а BFG — это projectile с авто-наведением. Для аркадных шутеров и быстрых PvP обычно берут комбинированный подход: основное оружие на hitscan ради отзывчивости, а тяжёлое и «уворачиваемое» — гранатомёт, ракетница, лук — на projectile. Для тактических — только hitscan, без вариантов.

Тот самый промах из правой части — это и есть классическая боль projectile-AI: снаряд летит, а цель за время полёта уходит. Лечится упреждением — и промахи у движущейся мишени исчезнут.

2. Геометрия попадания: лучи, капсулы, и почему хитбоксы — не меши

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

Базовые ray–shape тесты

Любая система попаданий стоит на пяти-шести примитивах. Их полезно знать наизусть: физический движок прячет их внутри себя, но иногда вы пишете collision-код сами — для предикта, для AI, для аналитики «куда стрелял противник».

Луч–плоскость. Самый простой случай: одна формула, одно скалярное произведение. Если плоскость задана точкой p0 и нормалью n, а луч — origin o и направлением d, то параметрическое расстояние до пересечения это t = ((p0 − o) · n) / (d · n). Если знаменатель ноль — луч параллелен плоскости. Это базовый кирпич, на котором стоят более сложные тесты: AABB — это шесть таких проверок, OBB — те же шесть в локальном пространстве, mesh — миллион треугольников = миллион плоскостей. Кода тут на пару строк, отдельным листингом приводить не буду.

Луч–сфера. Решаем квадратное уравнение t²(d · d) + 2t(o − c) · d + |o − c|² − r² = 0, где c — центр сферы, r — радиус. Дискриминант скажет, попали или нет. Дёшево, используется для всего, что концептуально круглое, и как грубая обёртка перед точным тестом.

// Луч–сфера: квадратное уравнение, дискриминант скажет, попали или нет.
public static bool RaySphere(Vector3 origin, Vector3 dir,
                             Vector3 c, float r,
                             out float t)
{
    Vector3 m = origin - c;
    float b = Vector3.Dot(m, dir);
    float cc = Vector3.Dot(m, m) - r * r;
    if (cc > 0f && b > 0f) { t = 0f; return false; }     // мимо и удаляемся
    float disc = b * b - cc;
    if (disc < 0f) { t = 0f; return false; }             // мимо
    t = -b - Mathf.Sqrt(disc);
    if (t < 0f) t = 0f;                                  // источник внутри сферы
    return true;
}

Луч–AABB (axis-aligned bounding box). Slab method, шесть сравнений, никаких квадратных корней. Стандарт для грубой фазы: каждый объект в сцене сначала тестируется через AABB, и только если попало — переходим к точному тесту.

// Луч–AABB: slab method, шесть сравнений, никаких квадратных корней.
public static bool RayAABB(Vector3 origin, Vector3 dir,
                           Vector3 min, Vector3 max,
                           out float t)
{
    float tmin = float.NegativeInfinity, tmax = float.PositiveInfinity;
    for (int i = 0; i < 3; i++) {
        float o = origin[i], d = dir[i];
        if (Mathf.Abs(d) < 1e-6f) {
            if (o < min[i] || o > max[i]) { t = 0f; return false; }
        } else {
            float inv = 1f / d;
            float t1 = (min[i] - o) * inv;
            float t2 = (max[i] - o) * inv;
            if (t1 > t2) (t1, t2) = (t2, t1);
            if (t1 > tmin) tmin = t1;
            if (t2 < tmax) tmax = t2;
            if (tmin > tmax) { t = 0f; return false; }
        }
    }
    t = tmin >= 0f ? tmin : tmax;
    return tmax >= 0f;
}

Луч–OBB (oriented bounding box). Это AABB, повёрнутый в локальном пространстве объекта. Решается ровно так же: переводим луч в локальные координаты объекта через InverseTransformPoint/InverseTransformDirection и зовём тот же RayAABB с ±halfExtents. Отдельного листинга не заслуживает — это три строки поверх предыдущего.

Луч–капсула. Самый интересный случай для шутеров — потому что почти все хитбоксы в современных шутерах именно капсулы, и через эту функцию проходят все ваши попадания по противникам. Геометрически капсула — это отрезок a→b плюс радиус r: цилиндрическая «колбаса» с двумя полусферами на концах. Задача разваливается на две части.

Сначала считаем пересечение луча с бесконечным цилиндром вокруг оси капсулы. Это сводится к классической задаче «кратчайшее расстояние между двумя скрещивающимися прямыми» — бесконечным лучом и осью ab; в перпендикулярной к оси плоскости получается квадратное уравнение, по структуре похожее на ray–sphere. Дискриминант скажет, попали ли в цилиндр. Если попали — проверяем, что точка пересечения внутри отрезка [a, b]: считаем параметр u вдоль оси и принимаем попадание только если u ∈ [0, 1]. Если u выскочил наружу или цилиндр промахнулся совсем — переходим к концевым полусферам: считаем ray–sphere для центров a и b с тем же радиусом и берём ближайшее попадание.

То есть «луч–капсула» — это, по сути, ray–cylinder с проверкой диапазона по оси плюс два ray–sphere как fallback на полусферах концов. У Кристера Эриксона в Real-Time Collision Detection лежит готовая формула в замкнутой форме (closed-form): пара скалярных произведений, квадратный корень, несколько ветвлений — точный ответ за фиксированное число операций, без итераций. Что критически важно для серверной валидации попаданий, где время выполнения должно быть предсказуемым.

// Луч–капсула: отрезок a→b с радиусом r.
// Сводится к скрещивающимся прямым плюс две полусферы на концах.
public static bool RayCapsule(Vector3 origin, Vector3 dir,
                              Vector3 a, Vector3 b, float r,
                              out float t)
{
    Vector3 ab = b - a;
    Vector3 ao = origin - a;
    float abLen2 = Vector3.Dot(ab, ab);
    float abDotDir = Vector3.Dot(ab, dir);
    float abDotAo  = Vector3.Dot(ab, ao);

    float A = Vector3.Dot(dir, dir) - abDotDir * abDotDir / abLen2;
    float B = Vector3.Dot(dir, ao)  - abDotDir * abDotAo  / abLen2;
    float C = Vector3.Dot(ao, ao)   - abDotAo  * abDotAo  / abLen2 - r * r;

    if (Mathf.Abs(A) < 1e-6f) return Endcaps(origin, dir, a, b, r, out t);
    float disc = B * B - A * C;
    if (disc < 0f) return Endcaps(origin, dir, a, b, r, out t);

    t = (-B - Mathf.Sqrt(disc)) / A;
    if (t < 0f) return Endcaps(origin, dir, a, b, r, out t);

    Vector3 hp = origin + dir * t;
    float u = Vector3.Dot(hp - a, ab) / abLen2;
    if (u >= 0f && u <= 1f) return true;     // попали в цилиндрическую часть
    return Endcaps(origin, dir, a, b, r, out t);
}

static bool Endcaps(Vector3 o, Vector3 d, Vector3 a, Vector3 b, float r, out float t)
{
    if (RaySphere(o, d, a, r, out float ta) && RaySphere(o, d, b, r, out float tb))
        { t = Mathf.Min(ta, tb); return true; }
    if (RaySphere(o, d, a, r, out t)) return true;
    return RaySphere(o, d, b, r, out t);
}

Это те самые пять кирпичей, на которых стоит почти вся collision-математика шутеров: плоскость, сфера, AABB, OBB и капсула. В большинстве проектов вы их в чистом виде не пишете — Unity Physics или собственный физический движок уже всё инкапсулировали. Но знать, что внутри, полезно: AI с предиктом, спекулятивные ray-cast'ы для предсказания попаданий, аналитика — везде эти функции всплывают.

Почему хитбоксы — капсулы, а не меши

В первый раз сталкиваясь с хитбоксами, многие думают: «ну как же — у нас есть полигональный меш персонажа, давайте по нему и проверять». Да? Конечно же нет.

Во-первых, стоимость. Полигональный меш персонажа — это 5000–15000 треугольников, и проверка пересечения превращается в проход по каждому из них. Капсула — отрезок и радиус, всё разрешается одной формулой. На одну проверку разница в нагрузке на CPU выходит в 50–100 раз. На сервере, где в кадр прилетает несколько сотен проверок попаданий, эта разница становится решающей.

Во-вторых, стабильность. Меш привязан к скелету и анимации, поэтому он буквально дёргается каждый кадр — пальцы, складки одежды, висящий на спине рюкзак. Капсула стоит на жёсткой кости и не дёргается. Это критически важно: если хитбокс «дрожит», игроки начинают замечать «странные» промахи и попадания, и разработчики получают вечный поток баг-репортов «вы убрали хитбокс с головы».

В-третьих, дизайн отдельно от визуала. Хитбокс — это игровая механика, а меш — это визуал, и они должны управляться независимо. С капсулами вы можете сделать голову чуть больше визуальной модели для компенсации пинга или, наоборот, сузить торс, чтобы лучшие игроки чаще промахивались по краям. С полигональным мешем вы намертво привязаны к 3D-арту: художник перенарисовал плечи — изменилась игровая механика, и сетевую часть надо тестировать заново.

Стандартный набор для шутерного персонажа — 4–8 капсул: голова, торс, две руки, две ноги. В тактических играх (CS, Valorant) ещё отдельно бёдра и голени. В CS:GO стандарты хитбоксов перерабатывали несколько раз — самая громкая итерация была в патче от 15 сентября 2015, когда Valve полностью заменили старую боксовую систему на капсульную; в архивных сравнениях «до и после» видно, как они гонялись за «той самой» геометрией.

Hit zones и multipliers

Раз у нас несколько капсул на персонажа, логично каждой дать свой множитель урона. Стандартная пропорция для тактических шутеров: голова — ×4 (одношотный хедшот из винтовки), торс — ×1 (базовый урон), конечности — ×0.5–0.75 (царапаешь, но не убиваешь). Реализуется тривиально: при попадании знаем, в какую капсулу попали (по тегу, слою или ID), у каждой свой множитель.

public class Hitbox : MonoBehaviour {
    [SerializeField] float damageMultiplier = 1f;
    [SerializeField] BodyPart part = BodyPart.Torso;

    public void OnHit(float baseDamage, Vector3 hitPoint) {
        float dmg = baseDamage * damageMultiplier;
        var enemy = GetComponentInParent<Health>();
        enemy.TakeDamage(dmg, part);
    }
}

Лайфхак: квадраты вместо sqrt

Раз мы говорим о геометрии — банально полезный лайфхак для тех мест, где collision-код выполняется сотни раз в кадр. Если вам не нужна точная дистанция, а нужно сравнение «ближе ли цель радиуса R» — никогда не считайте sqrt. Сравнивайте квадраты:

// плохо: считаем дистанцию каждый кадр для всех врагов
float dist = Vector3.Distance(player.position, enemy.position);
if (dist < range) Hit();

// хорошо: тот же результат, в 5–10 раз быстрее в часто выполняемом коде
if ((player.position - enemy.position).sqrMagnitude < range * range) Hit();

На современных CPU это сэкономит вам считанные микросекунды, и в большинстве случаев такой micro-optimization в принципе не нужен. Но в тиках сетевой проверки попаданий на сервере, где может быть несколько сотен проверок в кадр, экономия становится осязаемой.

3. Спред пули: где разработчики ломают «честность»

Собственно, любое автоматическое оружие должно мазать. Если каждая пуля летит ровно в прицел — стрельба превращается в лазерную указку, и игроку нечего «осваивать». Поэтому у пули есть спред — случайное (или заранее заданное) отклонение от центра прицела. Казалось бы, элементарно. На практике — это место, где разработчики чаще всего ломают игровое ощущение, причём незаметно для самих себя.

Наивный подход — квадрат вместо круга

Первое, что приходит в голову: «возьму два независимых random'а, один на dx, другой на dy».

// классическая ошибка
float dx = (Random.value * 2f - 1f) * spread;
float dy = (Random.value * 2f - 1f) * spread;
// итог: квадратное распределение

Получается квадрат с биасом по углам: диагональ квадрата длиннее стороны в √2 раз, поэтому пуля в угловых направлениях улетает на spread * 1.41 вместо spread. Игроки чувствуют это как «нечестно», но сформулировать почему обычно не могут — мозг просто ждёт круг, а получает квадрат. Что с этим делать — вариантов два, и оба сводятся к тому, чтобы вместо квадрата раздавать точки внутри диска.

Rejection sampling и polar coordinates

Самое прямое решение — rejection sampling: кидаем точку в квадрат, и если она вне круга — кидаем ещё раз.

public static Vector2 RejectionSpread(float spread)
{
    while (true) {
        float dx = (Random.value * 2f - 1f) * spread;
        float dy = (Random.value * 2f - 1f) * spread;
        if (dx * dx + dy * dy <= spread * spread) return new Vector2(dx, dy);
    }
}

Площадь круга — это π/4 от площади квадрата, поэтому одна попытка успешна примерно в π/4 ≈ 0.785 случаев (то есть ~78%), а в среднем на одну точку уходит 4/π ≈ 1.27 итерации. Минус — число итераций индетерминированное, в худшем случае циклов может потребоваться много. Для большинства случаев это не проблема, но если вы пилите детерминированный сетевой код (а вам этого, поверьте, рано или поздно захочется), то rejection sampling — не ваш друг: каждый клиент дёрнет random разное число раз, и стейты разойдутся.

Хочется детерминизма и красоты — берите полярные координаты. Один random на угол, один на радиус — фиксированное число операций.

public static Vector2 PolarSpread(float spread)
{
    float angle = Random.value * Mathf.PI * 2f;
    float radius = spread * Mathf.Sqrt(Random.value);   // ← важен sqrt!
    return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
}

Почему именно sqrt? Если просто написать radius = spread * Random.value, точки сожмутся к центру, потому что у вас линейное распределение по радиусу, а площадь круга растёт квадратично. Получится не равномерный диск, а bull's-eye с плотным центром. Чтобы плотность была равномерной по площади, нужно «растянуть» радиус через sqrt. Это та же история, что в Monte-Carlo интеграции: равномерное распределение по r не равно равномерному распределению по диску. И да — здесь sqrt, наоборот, обязателен.

Gaussian — для «честного, но рандомного» огня

Если вам нужно «большинство пуль рядом с центром, редкие — далеко», берите Box-Muller — преобразование, которое из пары равномерных чисел делает нормально распределённые. Это та самая работа Box & Muller (1958).

public static Vector2 GaussianSpread(float sigma)
{
    float u1 = Mathf.Max(Random.value, 1e-7f);  // log(0) — undefined
    float u2 = Random.value;
    float r = Mathf.Sqrt(-2f * Mathf.Log(u1)) * sigma;
    float t = 2f * Mathf.PI * u2;
    return new Vector2(r * Mathf.Cos(t), r * Mathf.Sin(t));
}

sigma контролирует ширину: меньше — точнее, больше — шире. В CS такой подход применяют для оружия sub-tier'а — FAMAS, Galil, кастомные пистолеты, — где хочется «случайно, но правдоподобно», с мягким центром и редкими далёкими выбросами.

Детерминированные паттерны — Vandal и Phantom

Теперь радикальный поворот. В Valorant у Vandal'а рандома вообще нет. Каждая пуля в очереди — это заранее заданный offset из таблицы. Pro-игроки могут учить паттерны до почти полной воспроизводимости — и это не баг, а ровно то, чего Riot хотели (студия открыто говорила, что точность оружия в Valorant детерминирована). Минус понятен: если у двух игроков одинаковая позиция и одинаковая очередь, у них будет одинаковое попадание. Поэтому Riot балансируют точность через «горизонтальный sway» — после нескольких пуль начинается случайная горизонталь, ломающая идеальную копию.

private static readonly Vector2[] VandalPattern = new Vector2[] {
    new(0, 0),    new(0, -7),   new(0, -16),  new(0, -25),
    new(-1, -34), new(-2, -42), new(-5, -48), /* ... до 30 точек */
};

public static Vector2 GetSpreadOffset(int bulletIndex, Vector2[] pattern)
{
    return pattern[Mathf.Min(bulletIndex, pattern.Length - 1)];
}

4. Recoil patterns: почему AK-47 рисует семёрку

Чтож, со спредом разобрались. Но в шутерах есть отдельная вещь — recoil, отдача. И это не то же самое, что спред. Спред — это случайный (или фиксированный) разброс пуль вокруг прицела. Recoil — это смещение самого прицела вверх и в стороны после каждого выстрела. В Counter-Strike из этой механики выросла целая киберспортивная дисциплина — изучение spray patterns: игроки годами учат, как именно после первой пули AK поднимается ровно вверх, после четвёртой — резко влево, после девятой — назад вправо.

Что вообще такое recoil-паттерн

В принципе всё просто. Каждая последующая пуля в очереди добавляет смещение прицела по фиксированному (или почти фиксированному) набору offset'ов. Через 0.3–0.5 секунды паузы между выстрелами паттерн сбрасывается. Это не баг, а механика: игрок учит паттерн и получает преимущество за мастерство.

Насколько жёстко зафиксирован паттерн — каждая игра решает по-своему. В CS он фиксирован почти полностью: есть небольшой случайный jitter, но шейп один и тот же. В Battlefield и Tarkov — наоборот, рандомизация играет большую роль.

Структура паттерна на примере AK-47 в CS

Если посмотреть на реальный spray AK-47, видна характерная форма «семёрки» (или зеркального «Z»):

  • Пули 1–4 — почти прямо вверх, vertical kick.

  • Пули 5–9 — резко влево, horizontal sway.

  • Пули 10–15 — назад вправо, balancing the kick.

  • Дальше — мелкое колебание, всё равно мажет.

Поэтому pro-игроки в CS на полной очереди тянут мышь по «обратной семёрке» — вниз и в обратные стороны, чтобы скомпенсировать паттерн.

Реализация: таблица или формула

Тут два подхода, и оба используются. Первый — lookup table, самый прямой и прозрачный: массив [(dx, dy)] на 30 элементов. Балансится через подкручивание чисел.

private static readonly Vector2[] Ak47Pattern = new Vector2[] {
    new(0, 0), new(0, -8), new(0.5f, -18), new(-0.5f, -28),
    new(-1, -38), new(-3, -46), new(-7, -52), new(-12, -56),
    /* ... до 30 точек */
};

public Vector2 GetRecoilOffset(int bulletIndex) {
    return Ak47Pattern[Mathf.Min(bulletIndex, Ak47Pattern.Length - 1)];
}

Второй — procedural: функция от bulletIndex через perlin-noise или несколько синусоид с разными фазами.

public Vector2 ProceduralRecoil(int i) {
    float verticalKick = -i * 4f;
    float horizontalSway = Mathf.Sin(i * 0.6f) * (i * 0.5f);
    return new Vector2(horizontalSway, verticalKick);
}

Procedural сложнее в балансе, но даёт «непохожесть» на конкурентов и легко скейлится по числу пуль. На практике AAA-шутеры с фиксированными узнаваемыми паттернами почти всегда сидят на lookup table — её можно вручную вылизать до миллиметра, а игрок именно эту фигуру и заучивает.

Recoil compensation — как игроки «компенсируют»

Pro-игрок в CS знает, что после выстрела AK прицел поедет вверх и влево. Он тянет мышь вниз и вправо — в реальном времени, по выученному паттерну. В результате на экране пули летят в одну точку. В играх с randomness в паттерне (Battlefield, Tarkov) полная компенсация невозможна — есть «потолок» точности, потому что часть смещения это настоящая случайность. Это сознательный выбор баланса: «мастерство имеет значение, но не отменяет случайность».

5. Упреждение: квадратное уравнение для AI-снайперов

Итак, у нас есть projectile-снаряд. Он летит со своей скоростью, и если мишень движется — целиться надо не туда, где мишень сейчас, а туда, где она окажется к моменту попадания пули. Эту точку называют упреждением, англоязычные коллеги — leading the target. Это и есть то, чем мы обещали в первой части «починить» промахи projectile-AI. По сути же — это банально квадратное уравнение, и решается оно за десять строк кода.

Постановка задачи

Снайпер стоит в точке P_s. Цель находится в точке P_t и движется со скоростью V_t (примем, что прямолинейно и с постоянной скоростью — упрощение, но именно эта модель работает в большинстве AI-снайперов). Снаряд летит со скоростью S. В какую точку нужно стрелять, чтобы попасть?

В момент попадания цель окажется в точке P_t + V_t · t, где t — время полёта снаряда. Снаряд за то же время должен пролететь расстояние, равное S · t. Значит, условие попадания:

|P_t + V_t · t − P_s| = S · t

Возводим в квадрат, чтобы избавиться от модуля. Обозначим D = P_t − P_s (вектор от стрелка до цели):

(D + V_t · t) · (D + V_t · t) = S² · t²

Раскрываем скалярные произведения и собираем по степеням t:

(|V_t|² − S²) · t² + 2(V_t · D) · t + |D|² = 0

Получили квадратное уравнение относительно t вида a·t² + b·t + c = 0, где:

a = |V_t|² − S²b = 2 · (V_t · D)c = |D|²,        D = P_t − P_s

b = 2 · (V_t · D)

c = |D|², D = P_t − P_s

Дальше — школа.

Разбираем дискриминант

Дискриминант Δ = b² − 4ac (стандартный, не путать с вектором D выше).

  • Δ > 0: два корня. Из них берём меньший положительный — попадаем раньше.

  • Δ < 0: вещественных корней нет, попасть невозможно (как правило — когда цель убегает быстрее снаряда и оторваться от неё нечем).

  • Δ = 0: один корень, граничный случай — тангенциальное попадание. Бывает редко, но численно стоит ловить.

  • a = 0: квадратное вырождается в линейное (|V_t| = S ровно). Корней либо один, либо нет — обработать отдельно.

Готовый код

Возвращаем Vector3? — null означает «попасть невозможно». Или кодом:

public static Vector3? AimLead(
    Vector3 shooterPos, Vector3 targetPos,
    Vector3 targetVel, float bulletSpeed)
{
    Vector3 D = targetPos - shooterPos;
    float a = Vector3.Dot(targetVel, targetVel) - bulletSpeed * bulletSpeed;
    float b = 2f * Vector3.Dot(targetVel, D);
    float c = Vector3.Dot(D, D);

    // Вырожденный случай: |V_t| = S — квадратное превращается в линейное.
    if (Mathf.Abs(a) < 1e-6f) {
        if (Mathf.Abs(b) < 1e-6f) return null;
        float t0 = -c / b;
        return t0 > 0f ? (Vector3?)(targetPos + targetVel * t0) : null;
    }

    float disc = b * b - 4f * a * c;
    if (disc < 0f) return null;                          // Δ < 0 — попасть нельзя

    float sd = Mathf.Sqrt(disc);
    float t1 = (-b - sd) / (2f * a);
    float t2 = (-b + sd) / (2f * a);

    // Берём меньший положительный корень — попадаем раньше.
    float t = float.MaxValue;
    if (t1 > 0f && t1 < t) t = t1;
    if (t2 > 0f && t2 < t) t = t2;
    if (t == float.MaxValue) return null;

    return targetPos + targetVel * t;
}

Где это применяют

  • AI-снайперы в shooter'ах с medium-fast снарядами — Halo Brutes, артиллерия в Helldivers 2.

  • Авто-наведение в аркадных играх и на мобиле (Free Fire, всякие метательные снаряды в духе Clash Royale).

  • Турели в tower defense.

  • Indicator упреждения в прицелах танковых симуляторов — War Thunder, World of Tanks. Там, кстати, прямо в HUD рисуют точку, в которую игроку нужно целиться.

Эта модель работает идеально только для прямолинейного движения с постоянной скоростью. Если цель ускоряется или меняет направление — результат становится приблизительным. Для более точного упреждения используют итеративные методы (повторяют вычисление с уточнённой позицией), но это уже про численные методы, и про них я как-нибудь напишу отдельно.

6. Lag compensation: где ты попал, но не засчиталось

Чтож, мы дошли до самого болезненного. До той самой ситуации, где вы стреляли в голову противника, у вас на экране попали, а сервер сказал «промах». Это не баг и не ваш плохой интернет — это netcode так устроен. И за выбором, как именно netcode устроить, стоит честный trade-off.

Откуда вообще берётся проблема

В сетевом шутере у клиента нет «настоящего» мира. Есть собственная локальная симуляция, обновляемая по snapshot'ам с сервера. Между фактической позицией игрока на сервере и тем, что видит клиент, всегда есть задержка из двух слагаемых:

  • Пинг — туда-обратно сетевая задержка, обычно 20–80 мс на хорошем соединении, до 200+ мс на плохом.

  • Interpolation buffer — клиент сознательно отстаёт от сервера на 50–100 мс, чтобы плавно интерполировать между snapshot'ами вместо джиттера.

В сумме клиент видит мир «в прошлом» примерно на 80–200 мс. Когда вы стреляете, вы стреляете в то, что видите, — то есть в позицию противника, какой она была сотню миллисекунд назад. На сервере противник за это время уже сдвинулся, и если сервер проверяет попадание по текущей позиции — вы промазали, хотя на вашем экране попали идеально.

Решение Valve: server rewind

Идея до гениальности проста, и канонически её описал Yahn Bernier из Valve ещё на GDC 2001. Сервер хранит историю позиций всех игроков за последнюю секунду — кольцевой буфер snapshot'ов. Когда клиент посылает выстрел, к пакету прикладывается метка времени: «выстрелил в момент t по своим часам». Сервер откатывает позиции хитбоксов к этому моменту, проверяет попадание и восстанавливает текущее состояние мира.

Псевдокод серверного hit-checker'а в Unity-стиле

public void ProcessShot(ShotPacket shot)
{
    // когда клиент видел эту картину, по часам сервера
    float compTime = shot.ClientTime - shot.Ping * 0.5f;

    // откатываем хитбоксы всех игроков на этот момент
    var snapshot = playerHistory.GetAtTime(compTime);
    ApplySnapshot(snapshot);

    // проверяем попадание стандартным Physics.Raycast
    if (Physics.Raycast(shot.Origin, shot.Direction, out var hit, shot.MaxRange,
                        hitboxLayerMask)) {
        var target = hit.collider.GetComponentInParent<Health>();
        if (target != null) target.TakeDamage(shot.Damage);
    }

    // возвращаем мир в текущее состояние, чтобы остальная симуляция не сошла с ума
    RestoreCurrent();
}

Маленькая оговорка к формуле compTime: в боевых движках к Ping * 0.5 обычно добавляют ещё и величину interpolation-буфера (те самые 50–100 мс), потому что клиент видит мир в прошлом не только из-за пинга. Я это опустил, чтобы не загромождать псевдокод, — но в реальном откате его учитывают.

Это стандарт Source / GoldSrc (Valve), Apex Legends на Source 2 (с поправками), у Overwatch свой netcode со своими тонкостями, но идея та же. Игрок этого механизма не видит — но именно из-за него возникает побочный эффект, который видит и ненавидит жертва.

Trade-off: «убит из-за угла»

У server rewind есть жёсткий побочный эффект. Жертва уже скрылась за угол на своём экране — а на сервере её откатили под выстрел стрелка. С точки зрения жертвы: «я была в безопасности, за углом, и всё равно умерла».

Можно ли это исправить? По сути нет — это объективное следствие выбора «приоритет за стрелком». Если бы сервер использовал текущую позицию, а не откат, то промах был бы у стрелка, и жалоб «я попал, не засчиталось» стало бы кратно больше. Это сознательное решение жанра: лучше иногда «убит из-за угла», чем регулярно «попал, не засчиталось». В CS, Apex и Valorant это считается приемлемой ценой за «попадаешь там, куда целишься». В играх с большим пингом разница становится заметнее, и это одна из причин, почему 64-tick CS:GO регулярно ругали по сравнению с 128-tick faceit-серверами — чем чаще тики, тем точнее rewind и тем меньше эффект «убит из-за угла».

Альтернатива: client authority

Можно вообще выкинуть rewind и доверить стрелку решать, попал он или нет. Клиент сам говорит «я попал», сервер просто верит.

// На стрелке:
void Fire() {
    if (Physics.Raycast(ray, out var hit)) {
        var enemy = hit.collider.GetComponent<Enemy>();
        if (enemy != null) {
            // отправляем серверу: "я попал в enemy.id, нанесите урон"
            networkClient.Send(new HitPacket { targetId = enemy.id, damage = 25 });
        }
    }
}

Минимум CPU на сервере, минимум confusion для жертвы, никакого rewind. Проблема очевидна: легко читерить. Просто отправляй «попал» каждый раз — сервер засчитает. Поэтому client authority для PvP-шутеров — табу. Где это работает: кооперативные игры против AI — Borderlands, Destiny boss fights, любой co-op шутер. Там cheating не критичен (вред от него ограничен своей же командой), и упрощённый netcode даёт лучший feel.

Что в итоге и куда копать дальше

Чтож, мы прошли шесть базовых задач шутерной стрельбы. По одному предложению на каждую — на случай, если вы листали по диагонали:

  • Hitscan vs projectile — выбор между лучом (мгновенно, дёшево, нет уворота) и снарядом (симуляция, дороже, можно увернуться).

  • Геометрия попадания — хитбоксы это капсулы, а не меши; в часто выполняемом коде сравнивайте квадраты дистанций.

  • Спред — два независимых random дают квадрат вместо круга; правильное решение — полярные координаты с sqrt.

  • Recoil patterns — детерминированная таблица offset'ов плюс компенсация мышью; это про мастерство, а не баг.

  • Упреждение — банально квадратное уравнение по t, корни делятся на «попасть можно» и «нельзя» через дискриминант.

  • Lag compensation — сервер откатывает время на момент выстрела, побочный эффект — «убит из-за угла» с точки зрения жертвы.

Чего нет в этой статье

Я сознательно оставил за бортом несколько больших тем:

  • Баллистика снайперок — drop пули по дистанции, ветер, температура воздуха. Отдельная тема, важная для военных шутеров и снайперских симуляторов вроде Sniper Elite.

  • Penetration через стены и материалы — в CS и Tarkov это глубокая система с разной плотностью материалов, замедлением пули и переменным damage falloff'ом.

  • Ricochet и rebound — отскоки. В Destiny, Halo, Doom Eternal на этом играют отдельные оружия.

  • Damage falloff — простая механика, но требует отдельного баланса. Зачем дробовику плохо стрелять на дистанции — отдельный разговор.

  • Звук, кросс-эффекты, partial visibility, бронирование — тут уже стык с graphics, audio и UX.

Если эта тема вам в принципе заходит — я веду телеграм-канал «математика в геймдеве по-простому»: там такие разборы выходят короче и чаще, и туда же первыми попадают анонсы новых статей. Заходите.

Что почитать дальше (только то, в чём я уверен):

  • Christer Ericson, Real-Time Collision Detection (Morgan Kaufmann, 2004) — библия ray–shape тестов, в том числе closed-form ray–capsule из части 2.

  • Yahn W. Bernier (Valve), Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization (GDC 2001) — каноника по lag compensation. Плюс статья Source Multiplayer Networking в Valve Developer Community wiki — то же на пальцах.

  • Gabriel Gambetta, Fast-Paced Multiplayer (gabrielgambetta.com) — лучший связный разбор client prediction, server reconciliation, entity interpolation и lag compensation в одном месте.

  • Glenn Fiedler, gafferongames.com — теория сетевых игр, тики, снапшоты, надёжный UDP.

  • Box, G.E.P. & Muller, M.E. (1958), A Note on the Generation of Random Normal Deviates (Annals of Mathematical Statistics 29(2):610–611) — та самая формула Box-Muller для Gaussian-спреда.

  • CS:GO patch от 15 сентября 2015 (liquipedia.net/counterstrike/2015-09-15_Patch) — переход на капсульные хитбоксы.

  • Battle(non)sense на YouTube — практические замеры netcode современных шутеров, если хочется увидеть rewind и tickrate в цифрах.

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