Deltadev-math.ruподписаться
// толпа · GPU

Толпа на максималках: flow fields, GPU и десятки тысяч юнитов в кадре

Продолжение разбора симуляции толпы. Flow field вместо N путей, симуляция агентов на GPU (compute + StructuredBuffer), indirect draw и LOD AI. Разбор Planetary Annihilation, Supreme Commander 2, AC Unity, Total War, Helldivers 2.

31 мая 2026·24 мин чтения·flow fieldscomputeLOD AI
Дейв и Delta смотрят на десятки тысяч юнитов, марширующих по GPU-сетке

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

В прошлый раз мы остановились на тысяче юнитов в кадре и честно оставили четыре вещи за скобками — flow fields, compute-боидов на GPU, GPU-driven rendering и LOD AI. Это продолжение той статьи, и если вы её не читали — там разобран весь CPU-стек симуляции толпы: три правила Рейнольдса, топологические соседи, spatial hash, ORCA и steering behaviors. Здесь я на них буду ссылаться как на уже знакомые кирпичи, так что при желании можно сначала сходить туда.

Тысяча юнитов на CPU — это разминка. А вот Париж с толпой на улицах, битва на пару полков в Total War или орда жуков, заливающая весь экран — это уже не «та же симуляция, только побольше». Это другая архитектура.

И устроена она как сэндвич из четырёх слоёв. Первый: глобальный путь перестаёт быть тысячей независимых A* и становится одной сеткой направлений — flow field. Второй: симуляция самих агентов переезжает с 8–16 ядер CPU на тысячи дорожек GPU — compute shaders и StructuredBuffer. Третий: рендер перестаёт быть draw call'ом на агента и становится indirect draw, где команды отрисовки генерит сам GPU. И четвёртый: то, что не видно вблизи, не симулируется честно — LOD AI и recycling.

Чтож, давайте по порядку. Как одно поле направлений заменяет тысячу путей, что такое StructuredBuffer и почему compute shader перемалывает толпу быстрее цикла for, как из десяти тысяч draw call'ов получается один, и что под капотом у Planetary Annihilation, AC Unity, Total War и Helldivers 2. Четыре интерактива в браузере прилагаются — покрутите прямо со страницы.

Если интересна тема — добро пожаловать.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

Часть 1. Потолок: почему большая толпа — это другая архитектура

Быстрый рекап, на чём мы закончили в прошлый раз. Весь CPU-стек толпы — это: локальные правила Рейнольдса (разделение, выравнивание, сплочение), spatial hash вместо перебора всех пар, ORCA для гарантированного обхода столкновений и steering behaviors для целей и обхода препятствий. Всё это крутится на нескольких ядрах процессора и спокойно тянет толпу в духе той, на которой мы закончили в прошлый раз. На таком масштабе сцена уже выглядит как настоящая толпа, и при этом ничего экзотического не нужно.

А теперь посмотрим, где живут большие массовки, чтобы было понятно, ради чего вообще городить новый огород.

Масштаб, на котором становится интересно

В Assassin's Creed Unity Ubisoft делали толпу революционного Парижа — и это, наверное, самый известный публично разобранный кейс. На GDC 2015 Франсуа Курнуайе из Ubisoft в докладе «Massive Crowd on Assassin's Creed Unity: AI Recycling» рассказывал, что на сцене одновременно бывало до десяти тысяч NPC — и держалось это не на честной симуляции каждого, а на хитром recycling'е (к нему вернёмся в Части 6).

Total War — другой жанр, та же боль: на поле одновременно тысячи солдат, и они должны держать строй, а не толпиться кашей. Тамаш Рабель, ведущий graphics-программист Creative Assembly, в разборе движка Total War на GPUOpen прямо пишет про «тысячи юнитов на экране» и тысячи уникальных матриц для скининга — то есть это официально подтверждённый порядок.

Где именно не справляется CPU

А не справляется он в трёх местах одновременно, и это не одна большая проблема, а три разные.

Путь. Если толпа идёт к одной точке и каждый юнит строит свой A* — это N независимых поисков пути в кадр, по одному на агента. A* сам по себе недешёвый, а целая пачка таких подряд — уже серьёзная доля кадрового бюджета. И чем больше карта, тем длиннее каждый путь, тем дороже.

Симуляция. Боиды, соседи, avoidance — всё это считается на CPU. Даже со spatial hash это десятки операций на агента в кадр, и крутится оно на 8–16 ядрах в лучшем случае. Современный процессор — это единицы-десятки потоков, и каждый поток грызёт агентов по очереди.

Рендер. Наивно каждый юнит — это свой draw call. Сколько юнитов, столько вызовов отрисовки — и тут упирается уже не GPU, а CPU, который эти вызовы готовит и шлёт драйверу.

И вот ключевая мысль всей статьи: на большой массовке эти три проблемы решаются не «оптимизацией того же кода», а сменой слоя. Путь становится одной сеткой направлений на всех. Симуляция переезжает на GPU. Рендер становится одним indirect-вызовом. А лишнее — перестаёт считаться вообще.

К этому моменту у нас есть карта боли: путь, симуляция, рендер. Давайте чинить по очереди, и начнём с самого наглядного — с пути.

Часть 2. Flow field: одна сетка направлений вместо N путей

Итак, проблема номер один. Тысяча юнитов идёт к одной цели. Наивно — это тысяча независимых A*: каждый юнит сам ищет себе путь по карте, сам разворачивает очередь, сам перебирает клетки. И вот что обидно: они все идут в одно и то же место. Тысяча почти одинаковых вычислений, результат которых процентов на девяносто совпадает.

Идея: считать не путь юнита, а путь к цели

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

Считаем его один раз. Дальше каждый юнит в каждом кадре просто смотрит в свою клетку и читает оттуда готовый вектор «иди туда». Это O(1) на запрос. Ни поиска, ни очереди, ни зависимости от длины пути.

Как это строится: волна, стоимость, направление

Три шага, и все три простые.

Шаг первый — сетка. Бьём карту на клетки. Часть клеток — стены (непроходимо).

Шаг второй — cost field. Запускаем волну от цели наружу. Это обычный Алгоритм Дейкстры — поиск кратчайших путей от одной вершины до всех остальных во взвешенном графе. Для сетки с равными весами вырождается в волну в ширину (BFS); с диагоналями — даёт честную стоимость с шагом 1 по стороне и √2 по диагонали. (или волна в ширину, если все шаги равны), только стартуем не из юнита, а из точки назначения. В каждой клетке записываем стоимость дойти до цели. Получается «карта высот»: у цели — ноль, чем дальше — тем больше, стены волна обтекает.

// обратная волна от цели — один раз на всё поле
var cost = new float[cols * rows];
Array.Fill(cost, float.PositiveInfinity);
cost[Index(goal)] = 0f;

var frontier = new MinHeap();           // приоритет — текущая стоимость
frontier.Push(goal, 0f);
while (frontier.Count > 0) {
    var c = frontier.Pop();
    foreach (var nb in Neighbors8(c)) {
        if (walls[Index(nb)]) continue;
        float nc = cost[Index(c)] + StepCost(c, nb);  // 1 по стороне, √2 по диагонали
        if (nc < cost[Index(nb)]) {
            cost[Index(nb)] = nc;
            frontier.Push(nb, nc);
        }
    }
}

Шаг третий — flow field. Превращаем стоимость в направление. В каждой клетке смотрим на соседей и берём вектор на того, у кого стоимость меньше. То есть «вниз по склону» к цели.

// в каждой клетке — направление на соседа с наименьшей стоимостью
var flow = new Vector2[cols * rows];
foreach (var c in AllCells()) {
    if (walls[Index(c)]) continue;
    var best = c;
    foreach (var nb in Neighbors8(c))
        if (cost[Index(nb)] < cost[Index(best)]) best = nb;
    flow[Index(c)] = ((Vector2)(best - c)).normalized;
}

И всё. Теперь юнит в кадре делает вот это:

// каждый юнит — просто читает свою клетку. O(1), без поиска.
Vector2 dir = flow[Index(CellOf(unit.position))];
unit.velocity += dir * accel * dt;

Почему это дёшево

Стоимость построения поля — это O(числа клеток), и она не зависит ни от длины пути, ни от числа юнитов. Построили один раз — и амортизировали на всю толпу. Тысяча юнитов или сто тысяч — поле то же самое, разница только в том, сколько раз его прочитали, а чтение это O(1).

По сути мы заменили «тысячу дорогих вопросов» на «один дорогой вопрос плюс тысячу дешёвых ответов». И вот это та самая смена слоя, про которую я говорил в Части 1: не «A* стал быстрее», а «A* больше не нужен на каждого».

Honest hedge: одна цель — да, пятьсот разных — нет

Flow field гениален ровно в одном сценарии: общая цель (или небольшое число общих целей). Толпа валит в одну точку, отряд идёт к одному зданию, зомби текут к игроку — здесь поле выносит всё.

Как только у вас пятьсот юнитов с пятьюстами разными целями — магия исчезает. Строить пятьсот отдельных полей не дешевле, чем пятьсот A*, а скорее дороже. В этом случае старый добрый A* (или Detour из recastnavigation) на каждого — снова правильный инструмент. Так что flow field — это не «замена A*», а инструмент под конкретный сценарий.

Интерактив 1: Flow field playground

Ниже — сетка со стенами. Кликните по ней — поставите цель. Сразу же построится cost field (это фон-heatmap: ярко-мятный у цели, тёмно-синий далеко) и flow field (стрелки «куда идти»). Слайдером накидайте агентов — они потекут к цели по стрелкам.

Главное — тумблер A* на каждого / flow field. Под сценой два числа: время пересчёта при смене цели и «работа» — сколько клеток алгоритм реально перебрал. Переключите на A* на каждого и подвигайте цель: каждый агент пересчитывает свой путь, и «работа» подскакивает в тысячи раз. Переключите обратно на flow field — одно поле на всех. И да, миллисекунды тут честные, но это ваш браузер, а не игровая консоль — цифры иллюстрируют разницу подходов, а не перформанс конкретного движка.

агентов3 000
режим: flow fieldпересчёт цели: 0.0 мсработа: FPS: 60

Клик по сетке ставит цель, а если зажать и вести — цель тащится за курсором. «Работа» — это число клеток, которое алгоритм реально перебрал: одно поле на всю толпу против отдельного A* на каждого. Выкрутите агентов на 20 000, включите A* на каждого и поводите цель перетаскиванием — пересчёт начнёт занимать уже сотни миллисекунд вместо долей, и сцена превратится в слайд-шоу. Это ровно та причина, по которой так в проде не делают: тот же flow field ведёт ту же толпу за движущейся целью и держит 60 FPS.

Поле строится — отлично. Но в проде оно живёт не в вакууме: цель двигается, карта огромная, а само направление «в общем к цели» ещё не разводит юнитов между собой. Разберём нюансы.

Часть 3. Нюансы, без которых flow field в проде не живёт

Поле направлений в вакууме — это красиво, но в игре оно сразу обрастает оговорками. Разберём три самые частые, потому что без них демка остаётся демкой.

Цель двигается. И что теперь?

В RTS цель чаще всего статична. Вы кликнули в точку — поле построилось и живёт, пока юниты идут. Отряд топает к зданию — здание не убегает. Поле спокойно живёт секундами, а это сотни кадров на один пересчёт. Когда цель реально сдвинулась — пересчитываем, но раз в N сотен миллисекунд, а не каждый кадр.

А стоит ли это вообще пугаться из-за движения цели? Дейкстра на сетке 64×64 — это порядок единиц миллисекунд. Раз в несколько сотен миллисекунд это незаметно. А короткие, дёрганые сдвиги цели вообще не доходят до пересчёта поля — их гасит локальный слой, о котором ниже.

Карта огромная. Иерархические тайлы

Если карта — это не 64×64, а здоровенный открытый мир, строить одно поле на всю карту при каждом сдвиге цели уже накладно: клеток миллионы. Решение — иерархия.

Карту режут на тайлы. Между тайлами считают грубый путь (на уровне «через какие тайлы идти к цели»), а внутри тайла строят полноценный flow field — но только для тех тайлов, что лежат на грубом пути. Это классика, и опорная работа здесь — глава Элайджи Эмерсона «Crowd Pathfinding and Steering Using Flow Field Tiles» из сборника Game AI Pro (CRC Press, 2013). Эмерсон описывает именно тайловую схему — она выросла из работы над Supreme Commander, и PDF лежит в открытом доступе, можно почитать первоисточник.

Сшивка слоёв: «в общем туда» плюс «не наступай на соседа»

Вот ключевой момент, который многие пропускают. Flow field говорит юниту направление в общем — «вон туда, к цели». Но он ничего не знает про то, что рядом ещё триста таких же юнитов, и если все тупо пойдут по стрелке, они слипнутся в один пиксель.

Поэтому архитектура — двухслойная. Глобальный слой (flow field) даёт цель. Локальный слой (boids + ORCA из прошлой статьи) разводит юнитов между собой. Складываются они примерно так:

// глобальный слой: куда идти «в общем» — читаем поле за O(1)
Vector2 global = flow[Index(CellOf(unit.position))];

// локальный слой: разведение с соседями — boids + ORCA из прошлой статьи
Vector2 local = Separation(neighbors) + OrcaAvoidance(neighbors);

// сумма с весами — и поехали
unit.velocity += (global * wGlobal + local * wLocal) * dt;

Собственно, вот и весь сэндвич: глобальный слой решает «куда», локальный — «как не толкаться по дороге». Flow field не отменяет boids и ORCA — он встаёт над ними и снимает с них задачу глобальной навигации.

Где это в RTS

Flow fields в стратегиях — это давно индустриальный стандарт. Supreme Commander 2 — тот самый кейс Эмерсона с тайлами. Planetary Annihilation — отдельный любопытный случай: бой идёт на сфере, поэтому сетка навигации натянута на поверхность планеты, а не на плоскость. Пасфайндер там, по словам разработчика Форреста Смита, воксельный и поддерживает сферические планеты и тысячи юнитов — про это будет подробнее в Части 7. Современные RTS в той или иной форме все живут на этой схеме: грубый глобальный слой плюс локальное разведение.

Интерактив 2: размер клетки и компромисс

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

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

клетка, px25
сетка: 36×20клеток: 720работа поля: 0 клетокпуть: средне

Толпа течёт слева к цели по тому же flow field, а поверх поля работает локальный слой boids из Части 3 (разделение и выравнивание) — поэтому юниты идут ровным потоком с зазором. Нажмите кнопку boids: выкл — останется чистый flow field, и на узких проходах толпа слипается в плотные комки; включите — разделение снова разводит её. Сиреневая линия — эталонный путь из левого нижнего угла. Грубее сетка — меньше клеток, дешевле расчёт (меньше «работы»), но путь блочный и хуже огибает углы. Тоньше сетка — путь глаже, но клеток (и работы) кратно больше. Этот компромисс в больших картах и снимают иерархией тайлов: грубый слой для дальнего планирования, тонкий — локально.

К этому моменту у нас есть глобальная навигация на всю толпу, которая дёшево строится, переживает движущуюся цель и масштабируется на большие карты. Но симуляция каждого агента — боиды, соседи, avoidance — всё ещё крутится на CPU. Пора переезжать.

Часть 4. Симуляция на GPU: StructuredBuffer и compute shaders

Ну хорошо, глобальный путь мы удешевили до одного поля. Но стоимость симуляции самого агента никуда не делась: разгон по направлению, чтение соседей, локальный avoidance — это всё ещё надо посчитать для каждого юнита в каждом кадре. И считается оно на CPU.

А CPU — это единицы-десятки потоков. Восемь, шестнадцать ядер в хорошем случае. Дискретная видеокарта — это тысячи дорожек, заточенных ровно под одно: сделать одно и то же простое действие над кучей данных разом. Симуляция толпы — это буквально оно: «возьми тысячи агентов и обнови каждого по одному правилу». Идеальная работа для GPU.

Что и зачем (без глубокого нырка)

Идея на пальцах. Позиции и скорости всех агентов кладём не в массив объектов на CPU, а в StructuredBuffer — типизированный буфер в видеопамяти, к которому compute/вершинный шейдер обращается по индексу как к массиву. RW-вариант (RWStructuredBuffer) доступен и на чтение, и на запись из шейдера. Это основной способ держать данные агентов на GPU. прямо в видеопамяти — например, float4 на агента, где xy это позиция, а zw это скорость. Дальше пишем Compute shader — программа на GPU, не привязанная к графическому конвейеру. Запускается «диспатчем» на сетке потоков, разбитой на thread groups (группы потоков). Внутри группы потоки могут делить общую память и синхронизироваться., который одним тиком обновляет всех агентов параллельно.

Выглядит примерно так:

// все агенты — в буфере. одна dispatch — тысячи потоков разом.
RWStructuredBuffer<float4> Positions;   // xy = позиция, zw = скорость
StructuredBuffer<float2>   Flow;        // тот самый глобальный flow field

[numthreads(256, 1, 1)]
void CSUpdate(uint3 id : SV_DispatchThreadID) {
    if (id.x >= AgentCount) return;          // хвост за границей буфера
    float4 a = Positions[id.x];
    float2 dir = Flow[CellIndex(a.xy)];      // читаем поле за O(1)
    a.zw += dir * Accel * DeltaTime;         // разгон по направлению
    a.xy += a.zw * DeltaTime;                // шаг
    Positions[id.x] = a;
}

А со стороны C# это один вызов на весь кадр — не цикл по агентам, а «запусти ядро на стольких-то группах»:

// один Dispatch — и GPU обновляет всех агентов сразу
int groups = Mathf.CeilToInt(agentCount / 256f);
compute.SetBuffer(kernel, "Positions", positionsBuffer);
compute.SetFloat("DeltaTime", Time.deltaTime);
compute.Dispatch(kernel, groups, 1, 1);

[numthreads(256,1,1)] — это размер группы: 256 потоков работают пачкой. Сколько таких групп нужно — считаем как agentCount / 256, округлив вверх. GPU раскидывает группы по своим вычислительным блокам и молотит их параллельно. Лучший публичный разбор того, как собрать compute-боидов с нуля — ролик Себастьяна Лага «Coding Adventure: Boids»: он гоняет очень большую стаю на compute shader и показывает каждый шаг.

А поиск соседей?

В прошлой статье spatial hash на CPU был обычным Dictionary (или Map в браузере). На GPU так нельзя — там нет удобных хеш-таблиц с динамическими списками. Поиск соседей делают иначе: считают, сколько агентов попало в каждую клетку (атомарный инкремент счётчика клетки), затем параллельным префиксным сложением (prefix sum) превращают счётчики в смещения, и наконец раскладывают агентов по клеткам в один плоский массив. Звучит как три простых шага, но каждый — отдельная dispatch, и отлаживать это заметно сложнее, чем Map в пару строк.

Цена вопроса

Честно: это не бесплатно по нервам.

  • Дебажить compute — это не дебажить C#. Брейкпоинт не поставишь, Debug.Log из шейдера не выведешь. Инструменты — PIX, RenderDoc, и это отдельный навык.
  • Spatial hash на GPU (atomic + prefix sum) — это десятки строк нетривиального кода против пары строк с Map.
  • Данные живут в видеопамяти, и каждый раз гонять их туда-сюда между CPU и GPU — дорого. Хочется, чтобы агенты жили на GPU и там же рендерились (про это — Часть 5).

Окупается это всё на десятках тысяч агентов. На тысяче — не трогайте GPU, CPU-стек из прошлой статьи и проще, и отлаживается легко. Это ровно тот случай, когда сложное решение оправдано масштабом, а не желанием поиграть с шейдерами.

Honest hedge: миллион в демо ≠ миллион в геймплее

В техдемках и research-статьях встречаются красивые числа — сотни тысяч, миллионы агентов на одной видеокарте. Это правда, но с оговоркой: в демо у агента логика на три инструкции, и больше в кадре нет ничего — ни геймплея, ни анимации, ни физики, ни звука, ни остального ИИ. В реальной игре GPU и CPU делят между десятком систем, и «миллион боидов» из техдемо превращается в куда более скромные числа в проде. Так что демо-числа — это потолок физики железа, а не обещание для вашего геймплея. Не выдавайте одно за другое.

Интерактив 3: CPU vs GPU как модель параллелизма

Сразу предупреждаю: это модель-иллюстрация, а не бенчмарк. Никаких выдуманных миллисекунд — мы считаем «шаги». Слева CPU: один цикл for, агенты обрабатываются по очереди, один за шаг. Справа GPU: одна dispatch, агенты красятся пачками по числу дорожек. Покрутите число дорожек и посмотрите на счётчики шагов внизу: CPU тратит N шагов, GPU — около N / дорожки. На реальной GPU дорожек тысячи (и есть свои оговорки — дивергенция веток, доступ к памяти), но порядок именно такой: вместо очереди — один широкий проход.

агентов N1024
дорожек64
шагов CPU: 0шагов GPU: 0модельное ускорение: ×64

Нажмите запуск — оба поля стартуют пустыми и заполняются «обработанными» агентами. CPU красит по одному за шаг, GPU — целую пачку по числу дорожек за шаг. Поле GPU заливается почти мгновенно, пока CPU ещё ползёт по первому ряду: это и есть разница. Это модель-иллюстрация, мы считаем шаги, а не миллисекунды; на реальной GPU дорожек тысячи, плюс свои оговорки (дивергенция веток, доступ к памяти).

Агенты теперь живут и считаются на GPU. Логично, чтобы они там же и рисовались — иначе мы упрёмся в третью проблему из Части 1. Идём в рендер.

Часть 5. Рендер: от draw call на агента к indirect draw

Третья проблема из Части 1, и самая коварная, потому что она не про GPU. Наивно каждый юнит рисуется своим вызовом отрисовки — draw call. Десять тысяч юнитов — десять тысяч draw call'ов. И тормозит тут CPU: он готовит каждый вызов, проверяет состояние, шлёт команду драйверу. GPU в это время скучает. Классический случай, когда видеокарта простаивает, а игра лагает.

Лечится это в три ступени — от простого к хардкору.

Ступень 1: GPU instancing

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

// один меш, массив матриц, один логический вызов на всю пачку
Graphics.DrawMeshInstanced(mesh, 0, material, matrices, count);

Тысяча юнитов — один вызов вместо тысячи. CPU выдыхает. Это самый дешёвый по усилиям приём, и большинству игр его хватает с головой. В вебе инстансинг работает из коробки: WebGL2 умеет его нативно (drawElementsInstanced), и ровно на нём стоит интерактив ниже.

Ступень 2: DrawMeshInstancedIndirect

У instancing'а есть потолок: матрицы всё ещё готовит и передаёт CPU. А мы только что в Части 4 положили позиции агентов на GPU. Гонять их обратно на CPU, чтобы собрать матрицы, и снова на GPU — это бессмысленно.

Indirect-вызов убирает CPU из уравнения. И число инстансов, и сами матрицы лежат в GPU-буфере. CPU говорит лишь «рисуй по этому буферу аргументов» — а сколько именно и с какими трансформациями, читается прямо из видеопамяти:

// число инстансов и матрицы — в GPU-буфере. CPU их даже не трогает.
// argsBuffer хранит {indexCount, instanceCount, startIndex, ...}
Graphics.DrawMeshInstancedIndirect(mesh, 0, material, bounds, argsBuffer);

Теперь compute shader из Части 4 может сам дописать в буфер аргументов, сколько агентов рисовать — и рендер подхватит это без участия процессора. В вебе этого уже нет: WebGL2 не умеет ни indirect-вызовов, ни compute-шейдеров, которыми такой буфер заполняют. Появляется это только в WebGPU.

Ступень 3: полноценный GPU-driven pipeline

Хардкор. GPU не просто рисует по буферу — он сам решает, что рисовать. Делает GPU culling — отсев невидимого (за камерой, за другими объектами, слишком мелкого) силами самого GPU, через compute shader, без участия CPU. Результат — компактный список того, что реально надо нарисовать, тут же скармливается indirect-вызову. (отсекает то, что за камерой или перекрыто), формирует список видимого и сам же его рисует. На уровне API это ExecuteIndirect в DX12 или vkCmdDrawIndirectCount в Vulkan, плюс persistent-буферы, которые живут между кадрами. В вебе — мимо: ни GPU-culling через compute, ни ExecuteIndirect WebGL2 не поддерживает (есть только расширение WEBGL_multi_draw — несколько диапазонов одним вызовом, но число и аргументы всё равно готовит CPU). По-настоящему это уже WebGPU, Vulkan и DX12.

Опорный материал тут — доклад Ульриха Хаара и Себастьяна Аалтонена «GPU-Driven Rendering Pipelines» с SIGGRAPH 2015 (курс Advances in Real-Time Rendering). Там подробно про mesh cluster rendering и culling силами GPU — это первоисточник. Подобные подходы (кластерный culling, GPU-driven отрисовка) живут в id Tech и других AAA-движках. UE5 Nanite — это вообще соседняя вселенная той же идеи «GPU сам разбирается, что и в каком детализации рисовать», но это отдельная большая тема, я её только упоминаю.

И к слову про Total War: Тамаш Рабель из Creative Assembly в разборе их движка описывает ровно эту боль — тысячи уникальных инстансов, тысячи матриц для скининга, и как они оптимизировали загрузку буфера инстансов в DX12, читая upload-кучу прямо с GPU, чтобы убрать лишние барьеры синхронизации на каждый draw call. Это то самое, только в проде и про скелетную анимацию.

Интерактив 4: instancing на three.js, честный замер

Здесь без моделек-иллюстраций. Поднимается настоящая 3D-сцена на three.js (WebGL2): пол-чертёж, стены и оранжевое кольцо цели (как goal-маркер из Demo 1), а толпа мятных человечков бежит к цели через стены, читая тот же flow field из Части 2. Каждый человечек — капсула плюс гранёная голова, слитые в одну геометрию (чтобы инстансинг считался честно, одним вызовом). Камеру можно вращать и приближать колесом, цель — перенести кнопкой. Тумблер наивно / instancing. Слайдер до 20 000 агентов. Все цифры в подписи — реальные: draw calls берётся из renderer.info.render.calls прямо после кадра, FPS — измеренный в браузере, а не нарисованный для красоты.

База сцены — пол, стены и кольцо цели — это ~3 draw call'а в любом режиме. К ним прибавляется отрисовка юнитов. В режиме наивно это N штук THREE.Mesh с общими geometry и material — renderer.info.render.calls ≈ N + 3. На паре тысяч агентов CPU уже захлёбывается готовить вызовы, а GPU простаивает, и FPS послушно проседает. Переключите тумблер на instancing — те же N юнитов рисуются одним THREE.InstancedMesh, draw call'ов становится ~4 на всю сцену, FPS возвращается к 60.

Чтобы толпа выглядела живой, а не ползла слипшимся комом, поверх flow field накручены boids — те самые три правила Рейнольдса (расталкивание, выравнивание курса, сплочение) из классики про стаи. Flow остаётся главным: он ведёт к цели, boids лишь добавляет локальную жизнь — люди держат дистанцию, выстраиваются в общие ручьи и огибают углы не строем. Соседей для флокинга ищем через uniform-grid с кэпом, иначе на 20 000 это честное O(N²) повесило бы вкладку. Снимите галочку boids (стая) — и живые ручьи рассыпаются в равномерную «крупу», где каждый сам по себе и все налезают друг на друга: вот это и есть вклад флокинга.

Маленькая оговорка: «draw call» здесь — это вызов отрисовки WebGL, как его считает three.js. У всех мешей выключен frustumCulled, чтобы счётчик не «помогал» нам сбоку и не выкидывал из подсчёта юнитов, которых не видно из текущего ракурса. Полноценный indirect-вызов (тот, что в DX12/Vulkan убирает CPU из уравнения совсем) в WebGL честно не показывается — он живёт ступенью выше и остаётся текстом этой части.

подгружаем three.js…
агентов N2 000
агентов: 2 000draw calls: 0треугольников: 0FPS: 0

Числа — реальные, из renderer.info вашего браузера. На картинке: пол-чертёж (1 вызов), стены — один InstancedMesh коробок (1), оранжевое кольцо цели (1). К этому в режиме наивно добавляется N штук THREE.Mesh с общими geometry и material — по draw call'у на агента. В instancing вся толпа рисуется одним THREE.InstancedMesh — итого ~4 вызова на всю сцену. Человечек — капсула и гранёная голова, слитые в одну геометрию (иначе инстансинг бы «соврал» лишним вызовом). Толпа бежит к цели через стены по тому же flow field из Части 2, а сверху — boids (расталкивание / выравнивание / сплочение), чтобы человечки не слипались в точку, а текли живыми ручьями; flow при этом главный, boids лишь подталкивает. Соседей берём через uniform-grid с кэпом — иначе флокинг на 20k был бы O(N²). Камеру можно вращать и приближать; цель — перенести кнопкой. У всех мешей выключен frustumCulled, чтобы счётчик не «худел» от тех, кого не видно из текущего ракурса. Indirect-вызов из DX12/Vulkan в WebGL честно не показывается — остаётся ступенью выше и текстом этой части.

Теперь у нас агенты считаются на GPU и рисуются почти без участия CPU. Осталась последняя, четвёртая идея — и она про то, чтобы вообще не делать лишнюю работу.

Часть 6. LOD AI и recycling: не симулировать то, чего не видно

Три предыдущие части мы делали работу дешевле: путь, симуляцию, рендер. Четвёртая идея радикальнее — не делать работу вообще.

Логика простая до наглости. Игрок физически не может уследить за десятью тысячами NPC одновременно. Он смотрит в свою сторону, видит десятки, ну сотни персонажей вблизи. Всё остальное — фон. Так зачем честно симулировать поведение того, кого никто не разглядывает?

Уровни детализации, только для мозгов

LOD AI — Level of Detail для искусственного интеллекта. По аналогии с графическим LOD (далёкие модели рисуют грубее) — далёкие и невидимые агенты получают всё более грубую логику: от полного дерева поведения вблизи до статистической «массовки» вдали. — это тот же принцип, что и графический LOD, только не про полигоны, а про поведение. Вблизи NPC получает полноценное дерево поведения: реагирует, уворачивается, живёт свою маленькую жизнь. Подальше — упрощённые правила. Ещё дальше — статистическая «марионетка»: не индивидуальная симуляция, а массовка, которая в среднем ведёт себя похоже на толпу, но без честных мозгов у каждого. Совсем далеко — выгружается вовсе.

И тут вступает recycling. NPC не удаляют и не создают заново (это дорого) — их переиспользуют. Ушёл за спину игрока — выгрузился из пула, появился впереди уже «другим» персонажем. Анимацию для тысяч NPC тоже упрощают и инстансят. По сути, толпа — это небольшой пул честных агентов плюс облако дешёвых статистов, и фокус в том, чтобы игрок никогда не поймал момент подмены.

Главный публичный кейс — AC Unity

Это ровно то, на чём держится Париж из Части 1. В докладе Франсуа Курнуайе «Massive Crowd on Assassin's Creed Unity: AI Recycling» (GDC 2015) описана как раз такая схема: при лимите порядка 40 «настоящих» AI и 120 высокодетализированных моделей на сцене ухитрялись держать до десяти тысяч NPC в кадре. Достигалось это пулом, который незаметно для игрока подменял низкодетализированных NPC на высокодетализированных по мере приближения. Вблизи — полное поведение, вдали — статистическая толпа, за камерой — выгрузка и переиспользование.

То есть «десять тысяч в Париже» — это не десять тысяч честных симуляций. Это сорок честных, сто двадцать «нарядных» и девять с лишним тысяч статистов, и вся инженерия — в том, чтобы стык был невидим. Именно LOD AI делает большую массовку вообще возможной: честно считается только то, что игрок реально разглядывает.

Honest hedge: почему тут нет интерактива

В отличие от первых четырёх частей, здесь нет демки — и это сознательно. LOD AI и recycling — это не один алгоритм, который можно крутить слайдером, а ворох продакшн-решений: пулы, пороги дистанций, бюджеты на «честных» агентов, тайминги подмены, инстансинг анимации. Честно показать это в браузере, не нафантазировав цифр, не выйдет.

К этому моменту у нас собрана вся архитектура: flow field для пути, GPU для симуляции, indirect draw для рендера, LOD AI чтобы не делать лишнего. Давайте посмотрим, что из этого реально подтверждено по конкретным играм, а что — наша догадка.

Часть 7. Под капотом: что мы знаем и не знаем про большие толпы

Planetary Annihilation (Uber Entertainment, 2014)

Бой в PA идёт на сфере — на поверхности процедурной планеты, а не на плоскости. Навигацию для этого писал Форрест Смит, и в своём портфолио он формулирует прямо: «я написал наш воксельный пасфайндер; он поддерживает сферические планеты, тысячи юнитов, разные размеры юнитов и разные типы движения». То есть сфера, тысячи юнитов и единый пасфайндер — это подтверждённый факт от автора. Сам подход «одно глобальное решение на большую толпу» Uber публично показывали в дев-материалах.

Supreme Commander 2 (Gas Powered Games, 2010)

Это канонический кейс flow field tiles. Элайджа Эмерсон описал тайловую схему в главе «Crowd Pathfinding and Steering Using Flow Field Tiles» (Game AI Pro, CRC Press, 2013), и выросла она из его работы над Supreme Commander 2. Глава лежит в открытом доступе — там разобрано всё, что мы обсуждали в Частях 2 и 3: тайлы, грубый путь между ними, локальный flow внутри.

Assassin's Creed Unity (Ubisoft Montreal, 2014)

Доклад Франсуа Курнуайе на GDC 2015 «Massive Crowd on Assassin's Creed Unity: AI Recycling»: recycling и LOD AI, порядка 40 «настоящих» AI и 120 высокодетализированных моделей, до десяти тысяч NPC в кадре, незаметная подмена low/high-res по дистанции. Есть и сопутствующий доклад Кристин Блондо про системные события в толпе того же года. Всё это разобрано в Части 6.

Total War (Creative Assembly)

На поле тысячи солдат, и держат они строй — это сама суть жанра. По рендеру есть отличный первоисточник: Тамаш Рабель, ведущий graphics-программист CA, в серии «Anatomy of the Total War Engine» на GPUOpen разбирает DX12, инстансинг тысяч юнитов и тысячи матриц для скелетной анимации — ровно то, о чём была Часть 5.

They Are Billions (Numantian Games, 2017–2019)

Движок самописный, и одна из его главных задач — одновременная симуляция и рендер десятков тысяч юнитов нежити. Разработчики в блоге и интервью говорили про кастомный pipeline с GPU-рендерингом для отрисовки орды и многопоточную логику.

Helldivers 2 (Arrowhead, 2024)

Игра сделана на Autodesk Stingray — движке, выросшем из Bitsquid, который Autodesk закрыл ещё в 2018-м. Это подтверждал публично сам Arrowhead (вплоть до CEO): студия тянула Stingray через несколько игр и допиливала его сама, без поддержки вендора. Так что про движок мы знаем, а вот про толпу термидов — нет.

Что объединяет все эти примеры

Паттерн один и тот же — тот самый сэндвич из четырёх слоёв: глобальная навигация (flow fields или навмеш), поиск соседей (spatial hash или аналог, на CPU или GPU), локальные правила и avoidance (boids/ORCA-семейство или эвристика), и сверху LOD AI, который выкидывает лишнее. Конкретные реализации у каждой студии свои, и полную схему никто наружу не выкладывает — это человеко-годы инженерии. Но кирпичи везде те же, и собрать из них рабочую толпу можете вы сами.

Эпилог: что мы получили и куда копать дальше

Соберём в одно место. Глобальный путь стал одним полем. Симуляция уехала на GPU. Рендер стал indirect-вызовом. А невидимое перестало считаться честно.

Сводная таблица слоёв

СлойЧто решаетОпорная работа
Flow fieldГлобальная навигация одним полем на всю толпу, O(1) на запросEmerson, Game AI Pro (2013)
Иерархические тайлыFlow field на больших картах: грубо между тайлами, точно внутриEmerson, Game AI Pro (2013)
GPU-симуляцияОбновление агентов на тысячах дорожек вместо цикла на CPUSebastian Lague, Coding Adventure: Boids
Spatial hash на GPUПоиск соседей без CPU: atomic-инкремент + prefix sumиндустриальная практика
GPU instancingОдин draw call на тысячу юнитовUnity Manual; Sebastian Lague
GPU-driven (indirect)GPU сам делает culling и решает, что рисоватьHaar & Aaltonen, SIGGRAPH 2015
LOD AI / recyclingНе симулировать невидимое, переиспользовать NPCCournoyer, AC Unity, GDC 2015

Что почитать и посмотреть дальше

  • Elijah Emerson. «Crowd Pathfinding and Steering Using Flow Field Tiles». Game AI Pro: Collected Wisdom of Game AI Professionals, CRC Press, 2013, глава 23. Первоисточник по тайловым flow fields, PDF в открытом доступе — gameaipro.com/GameAIPro/GameAIPro_Chapter23_Crowd_Pathfinding_and_Steering_Using_Flow_Field_Tiles.pdf.
  • Treuille, A., Cooper, S., Popović, Z. (2006). Continuum Crowds. ACM Transactions on Graphics 25(3):1160–1168. Академический референс «толпы как непрерывного поля», математика поверх flow-field-идеи. DOI 10.1145/1141911.1142008.
  • Sebastian Lague, Coding Adventure: Boids на YouTube. Боиды на compute shader с нуля — лучший публичный разбор GPU-стороны симуляции.
  • Ulrich Haar, Sebastian Aaltonen. «GPU-Driven Rendering Pipelines». SIGGRAPH 2015, курс Advances in Real-Time Rendering. Mesh cluster rendering и GPU culling — опорный материал по indirect-рендеру.
  • Francois Cournoyer. «Massive Crowd on Assassin's Creed Unity: AI Recycling». GDC 2015. Recycling и LOD AI на массовке Парижа.
  • Forrest Smith — портфолио. Автор пасфайндера Planetary Annihilation: воксельный пасфайндер на сферических планетах, тысячи юнитов.
  • Tamas Rabel. «Anatomy of the Total War Engine». AMD GPUOpen, 2016. Инстансинг тысяч юнитов и DX12-кухня от ведущего graphics-программиста Creative Assembly.
  • Helldivers 2 на Autodesk Stingray — материал PC Gamer с публичными словами Arrowhead про движок.
  • Mikko Mononen, recastnavigation. Recast/Detour с Detour Crowd — готовый ORCA-подобный локальный слой, связка с прошлой статьёй.

Заключение

Надеюсь, статья была полезна. Четыре интерактива, семь частей — и полная архитектура большой толпы: flow field для пути, GPU для симуляции, indirect draw для рендера, LOD AI чтобы не делать лишнего. Вместе с кирпичами из прошлой статьи этого достаточно, чтобы понимать, что лежит под капотом массовки в чужих играх, и собрать свою.

У меня есть телеграм-канал «математика в геймдеве по-простому», где я разбираю такие вещи короче и чаще. Заходите, если интересно.

// @easy_dev_math

Такие разборы — с кодом и интерактивами — выходят в канале каждую неделю.

Подписаться

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