惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V2EX - 技术
V2EX - 技术
P
Privacy International News Feed
Security Latest
Security Latest
H
Hacker News: Front Page
T
Tenable Blog
The Hacker News
The Hacker News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Security @ Cisco Blogs
Project Zero
Project Zero
O
OpenAI News
AI
AI
Spread Privacy
Spread Privacy
C
CERT Recently Published Vulnerability Notes
The Last Watchdog
The Last Watchdog
G
GRAHAM CLULEY
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Scott Helme
Scott Helme
Application and Cybersecurity Blog
Application and Cybersecurity Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
CXSECURITY Database RSS Feed - CXSecurity.com
NISL@THU
NISL@THU
A
Arctic Wolf
T
Threat Research - Cisco Blogs
PCI Perspectives
PCI Perspectives
N
News and Events Feed by Topic
C
Cyber Attacks, Cyber Crime and Cyber Security
C
Cybersecurity and Infrastructure Security Agency CISA
Simon Willison's Weblog
Simon Willison's Weblog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Know Your Adversary
Know Your Adversary
Google Online Security Blog
Google Online Security Blog
罗磊的独立博客
L
LINUX DO - 最新话题
U
Unit 42
S
Security Affairs
有赞技术团队
有赞技术团队
WordPress大学
WordPress大学
博客园 - 【当耐特】
T
The Exploit Database - CXSecurity.com
S
Schneier on Security
月光博客
月光博客
Engineering at Meta
Engineering at Meta
腾讯CDC
F
Full Disclosure
Cyberwarzone
Cyberwarzone
S
SegmentFault 最新的问题
Recorded Future
Recorded Future
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 司徒正美
The Cloudflare Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Стрельба в шутерах по-простому: от мгновенного луча до отката времени на сервере
Григорий Дядиченко · 2026-06-23 · via Все публикации подряд на Хабре

Стрельба в шутерах по-простому: от мгновенного луча до отката времени на сервере

Средний

21 мин

424

Всем привет! Меня зовут Гриша Дядиченко, я технический директор и основатель 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 в цифрах.

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