О стрельбе в играх по-простому
Всем привет! Меня зовут Гриша Дядиченко, и я технический продюсер. Уже больше десяти лет работаю с Unity — в основном это заказная разработка, AR/VR и время от времени собственные прототипы, до которых дотягиваются руки в свободное время.
Делал я как-то по вечерам свой прототип — не по работе, просто для себя. Небольшой сессионный аркадный шутер, перестрелка на пару игроков. Стрельбу, понятное дело, заложил себе на выходные: ну а что, raycast из ствола и событие попадания. В итоге следующие несколько недель я разбирался, почему игрок жмёт курок ровно в момент, когда прицел стоит на голове противника, а hit registration говорит «промах». Или «попадание», но не туда. Или туда, но уже после того, как противник успел отойти за угол. Собственно, тогда мне впервые стало понятно, что «стрельба за выходные» — это либо шутка, либо самообман.
Сталкивались ли вы с ситуацией, когда в шутере вы точно попали по противнику, а сервер сказал «промах»? Или с тем, что AI-противник стреляет в вас на сверхскорости снаряда и ни разу не попадает в движущуюся цель? Или с тем, что AK-47 в Counter-Strike рисует «семёрку» из пуль вверх и влево — и это, конечно же, никакой не баг, а вполне продуманная механика? Под капотом у всех этих ситуаций — конкретная математика, которую почему-то редко разбирают в одной статье.
Чтож, давайте разберём. Hitscan и projectile, как делать честный спред пули, откуда берутся «выученные» recoil-паттерны, как AI-снайпер вычисляет упреждение, и почему серверу иногда приходится «откатывать время» на сотню миллисекунд назад. Шесть разделов, в каждом — интерактив, который можно покрутить руками, и сниппеты на C#. Сразу оговорюсь: я сознательно не лезу в баллистику снайперок с ветром, в проникающие выстрелы через стены и в физику отдачи на уровне «как трясётся ствол». Иначе статья превратится в книжку, и никто её до конца не дочитает.
Если вам интересна тема — добро пожаловать.
[больше интересного в телеграм]Часть 1. Hitscan против projectile: а в чём вообще разница
Итак, вы поставили в сцену оружие и нажали кнопку «выстрел». Что должно произойти? У вас всего два честных ответа, и они реализованы по-разному.
Эту развилку я уже коротко обозначал в одном из постов у себя в телеграме — рейлган vs ракета. Но в одном посте не уместить хвост последствий, который тянется за выбором: lag compensation, recoil-паттерны, ощущаемая отзывчивость стрельбы. Поэтому здесь — подробно, с кодом и нюансами.
Hitscan — вы пускаете луч из ствола, в тот же кадр проверяете пересечение с миром, регистрируете попадание. Время полёта пули — ноль. Так стреляют все винтовки в Counter-Strike, Valorant, Call of Duty и в Overwatch — Soldier 76, Widowmaker.
Projectile — вы создаёте объект-снаряд, даёте ему скорость, и каждый тик симулируете его движение, проверяя коллизии. Так стреляют ракеты в Quake и Unreal Tournament, плазма в Halo, снайперки в Apex Legends, и вообще любое оружие в Splatoon.
Псевдокод обоих подходов
Hitscan на C# в Unity-стиле:
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 по сути — компромисс в пользу простоты и сетевой нагрузки. Серверу нужно меньше пересылать, клиенту — меньше симулировать, попадание ощущается мгновенным. Для тактических шутеров это важно: вы целились в голову, нажали — попадание должно быть бескомпромиссным. На длинных дистанциях projectile-снайперке надо реально «доехать» до цели, и игрок чувствует задержку как «оружие медленное и несправедливое».
Projectile, наоборот, открывает игроку возможность увернуться. В Quake вы можете шагнуть в сторону, увидев летящую ракету. В CS вы не можете «отойти от пули» — она уже попала в момент выстрела. Это совсем другое игровое ощущение.
Гибриды встречаются часто. В Halo battle rifle — hitscan, brute shot — projectile. В Apex R301 — hitscan, Kraber — projectile. В Doom Eternal плазма ведёт себя как hitscan-импульсы, а BFG — это projectile с авто-наведением.
На моём опыте — для аркадных шутеров и быстрых PvP я обычно беру комбинированный подход: основное оружие на hitscan ради отзывчивости, а тяжёлое и «уворачиваемое» — гранатомёт, ракетница, лук — на projectile. Для тактических — только hitscan, без вариантов: игрок целился в голову, нажал — попадание должно быть бескомпромиссным, и любая «летящая пуля» здесь читается как несправедливость.
Покрутите сами
Hitscan против projectile
В режиме HITSCAN мишень убегает, как может, но попадание мгновенное — луч ловит её ровно там, где она была в момент выстрела. Кстати, отсюда классическая боль hitscan-AI: если его не загрубить искусственно — разбросом, реакцией, задержками прицеливания — он будет попадать всегда, а это ощущается как явная несправедливость. В режиме PROJECTILE снаряд летит по своей физике, и если уменьшить его скорость и ускорить мишень, начинаются промахи. Вернёмся к этому интерактиву в части 5, добавим упреждение — и промахи исчезнут.
Часть 2. Геометрия попадания: лучи, капсулы, и почему хитбоксы — не меши
Чтож, мы решили, что стреляем лучом или снарядом. Дальше встаёт чисто геометрический вопрос: а с чем именно проверять пересечение? С полигональным мешем персонажа? Со сферой? С прямоугольником? Вариантов много, и почти все они в индустрии стандартизированы — давайте разберём, какие и почему.
Базовые ray–shape тесты
Любая система попаданий стоит на пяти-шести примитивах. Их полезно знать наизусть, потому что физический движок прячет их внутри себя, но иногда вы пишете collision code сами — для предикта, для AI, для аналитики. Каждому методу ниже — короткий разбор и сниппет на 10–25 строк, без зависимостей кроме UnityEngine.
Луч–плоскость. Самый простой случай: одна формула, одно скалярное произведение. Если плоскость задана точкой p0 и нормалью n, а луч — origin o и направлением d, то параметрическое расстояние до пересечения:
Если знаменатель ноль — луч параллелен плоскости. Базовый кирпич, на котором стоят более сложные тесты: AABB — это шесть таких проверок, OBB — те же шесть в локальном пространстве, mesh — миллион треугольников = миллион плоскостей.
// Луч–плоскость: одна формула, одно скалярное произведение.
// p0 — точка на плоскости, n — нормаль (нормированная).
public static bool RayPlane(Vector3 origin, Vector3 dir,
Vector3 p0, Vector3 n,
out float t)
{
float denom = Vector3.Dot(dir, n);
if (Mathf.Abs(denom) < 1e-6f) { t = 0f; return false; } // параллелен плоскости
t = Vector3.Dot(p0 - origin, n) / denom;
return t >= 0f;
}Луч–сфера. Решаем квадратное уравнение:
где c — центр сферы, r — радиус. Дискриминант скажет, попали или нет. Дёшево, используется для всего, что концептуально круглое — от пузырей экспериментов до broad-phase обёрток.
// Луч–сфера: квадратное уравнение, дискриминант скажет, попали или нет.
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, шесть сравнений, никаких квадратных корней. Стандарт для broad-phase: каждый объект в сцене сначала тестируется через 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, повёрнутый в локальном пространстве объекта. Решается так же, как AABB, только сначала переводим луч в локальное пространство и обратно.
// Луч–OBB: переводим луч в локальное пространство объекта и используем RayAABB.
// halfExtents — половина размеров OBB по локальным осям.
public static bool RayOBB(Vector3 origin, Vector3 dir,
Transform obb, Vector3 halfExtents,
out float t)
{
Vector3 lo = obb.InverseTransformPoint(origin);
Vector3 ld = obb.InverseTransformDirection(dir);
return RayAABB(lo, ld, -halfExtents, halfExtents, out t);
}Луч–капсула. Самый интересный случай для шутеров — потому что почти все хитбоксы в современных шутерах именно капсулы, и через эту функцию проходят все ваши попадания по противникам. Геометрически капсула — это отрезок a→b плюс радиус r: цилиндрическая «колбаса» с двумя полусферами на концах. Задача разваливается на две части.
Сначала считаем пересечение луча с бесконечным цилиндром вокруг оси капсулы. Это сводится к классической задаче «кратчайшее расстояние между двумя скрещивающимися прямыми» (бесконечный луч и ось ab); в перпендикулярной к оси плоскости получается квадратное уравнение, по структуре похожее на ray–sphere. Дискриминант скажет, попали ли в цилиндр. Если попали — проверяем, что точка пересечения внутри отрезка [a, b]: считаем параметр u вдоль оси и принимаем попадание только если u ∈ [0, 1]. Если u выскочил наружу или цилиндр промахнулся совсем — переходим к концевым полусферам: считаем ray–sphere для центров a и b с тем же радиусом, и берём ближайшее попадание.
То есть «луч–капсула» — это, по сути, ray–cylinder с проверкой диапазона по оси плюс два ray–sphere в качестве запасного аэродрома. У Кристера Эриксона в 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-математика шутеров. В большинстве проектов вы их в чистом виде не пишете — 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 даёт квадрат, а не круг и когда uniform-диск ломается о Gaussian. Здесь — полный разбор: rejection sampling, полярные координаты, Box-Muller, и заодно про детерминированные паттерны в духе Vandal'а.
Наивный подход — квадрат вместо круга
Первое, что приходит в голову: «возьму два независимых random'а, один на dx, другой на dy».
// классическая ошибка
float dx = (Random.value * 2f - 1f) * spread;
float dy = (Random.value * 2f - 1f) * spread;
// итог: квадратное распределениеПолучается квадрат с биасом по углам: диагональ квадрата длиннее стороны в √2 раз, поэтому пуля в угловых направлениях улетает на spread * 1.41 вместо spread. Игроки чувствуют это как «нечестно», но сформулировать почему обычно не могут — мозг просто ждёт круг. Что с этим делать — вариантов два.
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);
}
}В среднем 1 из 4/π ≈ 0.78 попыток успешна. Минус — индетерминированное число итераций, в худшем случае может потребоваться много циклов. Для большинства случаев это не проблема, но если вы пилите детерминированный сетевой код (а вам этого, поверьте, рано или поздно захочется), то rejection sampling — не ваш друг. Каждый клиент будет дёргать random разное число раз, и стейты разойдутся.
Polar coordinates — правильное и красивое решение
Хочется детерминизма и красоты — берите полярные координаты. Один 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 * Math.random(), точки сожмутся к центру, потому что у вас линейное распределение по радиусу, а площадь круга растёт квадратично. Получится не равномерный диск, а bull's-eye паттерн с плотным центром. Чтобы плотность была равномерной по площади, нужно «растянуть» радиус через sqrt.
Это та же история, что в Monte-Carlo интеграции — равномерное распределение в r не равно равномерному распределению на диске. Покрутите интерактив ниже, посмотрите сами.
Gaussian — для «честного, но рандомного» огня
Если вам нужно «большинство пуль рядом с центром, редкие — далеко», берите Box-Muller — формулу для генерации нормального распределения из равномерного.
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-игроки могут учить паттерны до 100% воспроизводимости — и это не баг, это ровно то, чего Riot хотели.
Минус понятен: если у двух игроков одинаковая позиция и одинаковая очередь, у них всегда будет одинаковое попадание. Поэтому 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)];
}Покрутите распределения сами
Спред: куда летят пули
Видно невооружённым глазом: BOX — квадрат с биасом по углам (это тот самый «cone spread», который на самом деле бокс). REJECT и POLAR — равномерный диск, причём POLAR — без рандомных циклов. GAUSS — мягкий центр с длинными хвостами. DETERM — рисует фиксированный паттерн.
Часть 4. Recoil patterns: почему AK-47 рисует семёрку
Чтож, со спредом разобрались. Но в шутерах есть отдельная вещь — recoil, отдача. И это не то же самое, что спред. Спред — это случайный (или фиксированный) разброс пуль вокруг прицела. Recoil — это смещение самого прицела вверх и в стороны после каждого выстрела.
В Counter-Strike из этой механики выросла целая киберспортивная дисциплина — изучение spray patterns. Игроки годами учат, как именно после первой пули AK поднимается ровно вверх, после четвёртой — резко влево, после девятой — назад вправо.
Что вообще такое recoil-паттерн
В принципе всё просто. Каждая последующая пуля в очереди добавляет смещение прицела по фиксированному (или почти фиксированному) набору offset'ов. Через 0.3–0.5 секунды паузы между выстрелами паттерн сбрасывается. Это не баг — это фича. Игрок учит паттерн и получает преимущество за мастерство.
Насколько жёстко зафиксирован паттерн — каждая игра решает по-своему. В CS он фиксирован на 90% — есть небольшой случайный 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);
}Сложнее в балансе, но даёт «непохожесть» на конкурентов и легко скейлится по числу пуль.
Recoil compensation — как игроки «компенсируют»
Pro-игрок в CS знает, что после выстрела AK прицел поедет вверх и влево. Он тянет мышь вниз и вправо — в реальном времени, по выученному паттерну. В результате на экране пули летят в одну точку.
В играх с randomness в паттерне (Battlefield, Tarkov) полная компенсация невозможна — есть «потолок» точности, потому что часть смещения настоящая случайность. Это сознательный выбор баланса: «мастерство имеет значение, но не отменяет случайность».
Покрутите паттерн сами
Recoil pattern viewer
Кнопка [ FULL AUTO ] выпускает все 30 пуль подряд, и вы видите классическую «семёрку» AK или более компактные паттерны M4/Phantom. Включите COMPENSATION — точки сядут в одну. Это и есть то самое мастерство, которое отличает casual'а от tier-1 player'а.
Паттерны — приблизительные, на основе публично известных spray maps.
[больше интересного в телеграм]Часть 5. Упреждение: квадратное уравнение для AI-снайперов
Итак, у нас есть projectile-снаряд из части 1. Он летит со своей скоростью, и если мишень движется — целиться надо не туда, где мишень сейчас, а туда, где она окажется к моменту попадания пули. Эту точку называют упреждением, англоязычные коллеги — leading the target.
По сути же — это банально квадратное уравнение, и решается оно за десять строк кода.
Постановка задачи
Снайпер стоит в точке P_s. Цель находится в точке P_t и движется со скоростью V_t (примем, что прямолинейно и с постоянной скоростью — упрощение, но именно эта модель работает в большинстве AI-снайперов). Снаряд летит со скоростью S. В какую точку нужно стрелять, чтобы попасть?
В момент попадания цель окажется в точке P_t + V_t · t, где t — время полёта снаряда. Снаряд за то же время должен пролететь расстояние, равное S · t. Значит, условие попадания:
Возводим в квадрат, чтобы избавиться от модуля. Обозначим D = P_t − P_s (вектор от стрелка до цели):
Раскрываем скалярные произведения и собираем по степеням t:
Получили квадратное уравнение относительно t:
Дальше — школа.
Разбираем дискриминант
Дискриминант D = b² − 4ac (стандартный, не путать с вектором D выше).
- D > 0: два корня. Из них берём меньший положительный — попадаем раньше.
- D < 0: корней нет. Попасть невозможно — это значит, что цель быстрее снаряда и убегает от стрелка. Никакое прицеливание не помогает.
- D = 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; // D < 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: каждый кадр (или раз в 0.1 секунды для CPU) пересчитываете и поворачиваете муздалом ствола в aimAt:
void UpdateSniperAim() {
Vector3? aimAt = AimLead(muzzle.position, target.position,
target.linearVelocity, bulletSpeed);
if (aimAt.HasValue) {
muzzle.rotation = Quaternion.LookRotation(aimAt.Value - muzzle.position);
canFire = true;
} else {
canFire = false; // цель быстрее снаряда — стрелять бессмысленно
}
}Где это применяют
- AI-снайперы в shooter'ах с medium-fast снарядами — Halo Brutes, Helldivers 2 артиллерия.
- Авто-наведение в аркадных играх и mobile (Free Fire, всякий Clash Royale-стиль метательные снаряды).
- Турели в tower defense.
- Indicator упреждения в прицелах танковых симуляторов — War Thunder, World of Tanks. Там, кстати, прямо в HUD рисуют точку, в которую игроку нужно целиться.
Эта модель работает идеально только для прямолинейного движения с постоянной скоростью. Если цель ускоряется или меняет направление — результат становится приблизительным. Для более точного упреждения используют итеративные методы (повторяют вычисление с уточнённой позицией), но это уже про численные методы, и про это я как-нибудь напишу отдельно.
Покрутите упреждение сами
Упреждение: квадратное уравнение в действии
В нижнем поле — текущие значения коэффициентов a, b, c, дискриминант D и (если решение есть) время полёта t. Потяните оранжевый наконечник — поменяете вектор скорости цели. Включите USE LEADING — стрелок начнёт целиться в упреждённую точку, и луч пойдёт в неё, а не в текущую позицию. Выключите — увидите, как пуля промахивается мимо движущейся цели.
Обратите внимание на красный режим: если цель движется от стрелка быстрее снаряда — D < 0, и попасть в принципе невозможно. Это не баг кода, это физическая невозможность ситуации, и AI должен это понимать (например, переключаться на другую цель).
Часть 6. Lag compensation: где ты попал, но не засчиталось
Чтож, мы дошли до самого болезненного. До той самой ситуации, где вы стреляли в голову противника, у вас на экране попали, а сервер сказал «промах». Это не баг и не лагает у вас — это netcode так устроен. И за выбором, как именно netcode устроить, стоит честный trade-off.
Откуда вообще берётся проблема
В сетевом шутере у клиента нет «настоящего» мира. Есть собственная локальная симуляция, обновляемая по snapshot'ам с сервера. Между фактической позицией игрока на сервере и тем, что видит клиент, всегда есть задержка:
- Пинг — туда-обратно сетевая задержка, обычно 20–80 мс на хорошем соединении, до 200+ мс на плохом.
- Interpolation buffer — клиент сознательно отстаёт от сервера на 50–100 мс, чтобы плавно интерполировать между snapshot'ами вместо джиттера.
В сумме клиент видит мир «в прошлом» примерно на 80–200 мс. Когда вы стреляете, вы стреляете в то, что видите, — то есть в позицию противника, какой она была сотню миллисекунд назад. На сервере противник за это время уже сдвинулся, и если сервер проверяет попадание по текущей позиции — вы промазали, хотя на вашем экране попали идеально.
Решение Valve: server rewind
Идея до гениальности проста. Сервер хранит историю позиций всех игроков за последнюю секунду — кольцевой буфер 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();
}Это стандарт Source / GoldSrc (Valve), Source 2 (Apex Legends с поправками), у Overwatch свой netcode со своими тонкостями, но идея та же.
Trade-off: «убит из-за угла»
Но у server rewind есть жёсткий побочный эффект, который игроки видят и ненавидят. Жертва уже скрылась за угол на своём экране — а на сервере её откатили под выстрел стрелка. С точки зрения жертвы, она была в безопасности 200 мс назад убилась насмерть.
Можно ли это исправить? Сегодня нет. Это объективное следствие выбора «приоритет — за стрелком». Если бы сервер использовал текущую позицию, а не откат, то промах был бы у стрелка, и жалоб «я попал, не засчиталось» стало бы в десять раз больше. Это сознательное решение жанра: лучше иногда «убит из-за угла», чем регулярно «попал, не засчиталось».
В CS, Apex и Valorant это считается приемлемой ценой за «попадаешь там, куда целишься». В играх с большим пингом разница становится заметнее, и это одна из причин, почему 64-tick CS:GO регулярно ругали по сравнению с 128-tick faceit-серверами — на 64 ticks 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.
Покрутите ситуацию сами
Lag compensation: где ты попал, но не засчиталось
Вид со стороны стрелка показывает две метки противника: тонкая пунктирная — реальная позиция на сервере, цветная — то, что видит стрелок (отстаёт на пинг + interp). Нажмите [ FIRE ] — система проверит попадание. С LAG COMPENSATION сервер откатывает позицию противника к моменту вашего выстрела и засчитывает hit. Без неё — использует текущую и засчитывает miss, потому что противник уже за стеной.
Поднимите пинг до 200+ — расхождение между «client» и «real» станет особенно драматичным.
[больше интересного в телеграм]Что в итоге
Чтож, мы прошли шесть базовых задач шутерной стрельбы. По одному предложению на каждую — на случай, если вы листали по диагонали и хотите быстрое summary:
- 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.
По любой из этих тем готов написать продолжение. На посте-анонсе этой статьи в телеграме я слежу за реакциями: если соберём 200 🔥, выпущу следующую статью. А в комментариях к тому же посту напишите, какая из тем выше зашла бы больше всего — баллистика, penetration, ricochet или что-то ещё.
Закрытие
Надеюсь, статья была полезна и хотя бы один из этих шести кусков пригодится в вашем проекте. Конечно же, моё решение — не единственное и не самое оптимальное. У многих студий есть свои нюансы, проприетарные подходы и отдельные хаки, которые здесь не уместились бы. Если у вас есть свой опыт со шутерной стрельбой — заходите в комментарии к посту-анонсу, буду рад обсудить, особенно интересны истории «как мы делали через А, потом перешли на Б».
У меня есть телеграм-канал «математика в геймдеве по-простому», где такие вещи разбираются короче и чаще. Заходите, если интересно.
[математика в геймдеве по-простому — заходите в телеграм]