6000 агентов на одном игровом потоке:
Йонджу «Kaley» Чо показывает нам, как создать целый стадион людей с помощью системы для управления толпой на базе GPU, используя Houdini и Unreal Engine, и объясняет логику, текстурирование и анимацию, лежащие в основе проекта.


Введение
Привет, я Кэйли Чо, технический художник, исследующий технические границы сред в реальном времени. Меня всегда увлекала задача балансировки экстремальной визуальной плотности с высокой производительностью, и этот проект родился из желания увидеть, смогу ли я создать по-настоящему массовую реагирующую толпу, которая чувствует себя живой, не нагружая процессор. Это была невероятно полезная головоломка, соединяющая процедурные настройки Houdini и оптимизированные шейдеры Unreal Engine.
Заполнение огромного боксёрского стадиона более чем 6 000 реагирующих зрителей обычно доводит системы скелетных сеток, привязанных к процессору, до предела. Перенося деформацию персонажей на GPU с помощью Vertex Animation Textures (VAT), я могу поддерживать высокую частоту кадров, сохраняя при этом высокое качество движения. В этом обзоре рассматривается полный конвейер: от процедурных настроек Houdini до запуска анимации на основе шейдеров, с акцентом на основных принципах технического искусства для современной разработки игр.
Эта статья описывает полный процесс от необработанной геометрии стадиона до живой, дышащей толпы.
Готовая система: 6 072 зрителя анимируются в реальном времени, управляемые через интерактивный пользовательский интерфейс. Время на GPU: 17,51 мс. Вызовы отрисовки: 276.
Часть 1: создание основы в Houdini
Шаг 1: извлечение позиций сидений
Первая проблема проста в описании и сложна в решении вручную: где сидит каждый зритель и в каком направлении он смотрит? Разместить 6 000 преобразований вручную невозможно, поэтому вместо этого работает геометрия сидений самого стадиона.
FBX стадиона импортируется в Houdini, геометрия стула выделяется, и цикл For Each перебирает все соединённые части. Внутри цикла VEX wrangle использует getbbox() для поиска ограничивающей рамки каждого сиденья и помещает одну точку в её центре с помощью addpoint(). В результате получается чистое облако точек без лишней геометрии.

Unreal Engine использует систему координат Z-up, в то время как Houdini по умолчанию использует Y-up. Чтобы убедиться, что ваши позиции VAT совпадают со статическими экземплярами сетки в движке, поменяйте местами атрибуты Y и Z и отрицайте новый Y.
// Houdini Z → Unreal Front/Back
// Houdini X → Unreal Left/Right
// Houdini Y → Unreal Up/Down
@P = set(@P.z, -@P.x, @P.y);
// Применяем то же преобразование к нормалям
@N = set(@N.z, -@N.x, @N.y);
Конечный узел в цепочке — Labs CSV Exporter. Экспортируются только четыре атрибута, которые нужны Unreal: RowName, Index, P (позиция, разделённая на Px/Py/Pz) и N (нормаль, разделённая на Nx/Ny/Nz). В результате получается облегчённая электронная таблица, которая становится единственным источником истины для всех преобразований более чем 6 000 сидений.

Внутри Unreal этот CSV импортируется как Data Table с помощью пользовательской структуры S_CrowdTable. В результате получается идеально организованная таблица всех сидений на арене.

Часть 2: запекание VAT в Houdini
Конвейер персонажей
Каждый персонаж-зритель сначала проходит подготовительный этап. Сетка очищается, поли-уменьшается до соответствующего LOD для трибун, и назначаются группы материалов (Body, Shirt, Hair), чтобы материал Unreal мог применять рандомизированные цвета для каждой группы.


Выпечка нескольких анимационных состояний
Толпе нужно больше одной анимации. Ей нужны: Idle (сидячее положение, едва заметное движение), Clap, Yell и Stand. Каждая анимация — это отдельная выпечка VAT.
Сеть использует общий OUT_DeformMesh, который подаётся на четыре параллельных узла ProcessCharacter, по одному на каждое анимационное состояние. Каждая ветвь деформирует сетку через анимационные кадры и выводит в свой собственный OUT_ null, который затем считывает Labs VAT ROP.

Настройки VAT ROP
Экспорт фактически осуществляет Labs Vertex Animation Textures ROP. Здесь несколько настроек имеют решающее значение:
- Method: Soft (Constant Topology) — топология сетки никогда не меняется между кадрами, что требуется для корректной работы VAT.
- Raster Depth: 16-Bit Floating Point — это не обсуждается. 8-битные текстуры не имеют достаточной точности для хранения смещений положения в мировом пространстве без видимого дрожания вершин, особенно когда камера находится близко к трибунам.
- Pack Normals into Position Alpha — упаковывает данные о нормали в альфа-канал текстуры положения, сохраняя слот текстуры.
- Engine: Unreal Engine — устанавливает правильные выходные соглашения координат для UE.

Часть 3: Шейдер Unreal Engine
Материал (M_VAT_HISM) — это то, где GPU выполняет всю свою работу. Он считывает текстуры VAT, прокручивает их во времени, смешивает между анимационными состояниями, рандомизирует цвета и обрабатывает волновой эффект, и всё это без единой инструкции CPU для каждого экземпляра во время выполнения.
UV: Прокрутка по текстуре анимации
Основная механика VAT заключается в том, что каждый анимационный кадр — это горизонтальная строка в текстуре. Чтобы воспроизвести анимацию, шейдер прокручивает координату V вниз с течением времени. Раздел UV обрабатывает это, создавая временную петлю на основе параметров FPS и количества кадров, затем используя PerInstanceRandom для смещения времени начала каждого экземпляра, чтобы они не анимировались синхронно. Без этого 6 000 символов будут считываться как одна плитка текстуры, а не как независимые агенты.

WPO: Динамическое смешивание анимации
Раздел World Position Offset одновременно считывает пять анимационных текстур (Anim1–Anim4 для четырёх базовых состояний, плюс AnimWave). RGB каждой текстуры хранит сжатые смещения положения, а параметры min/max ограничивающей рамки используются для их распаковки обратно в значения в мировом пространстве. Цепочка узлов Lerp затем смешивает между активными состояниями на основе альфа-значений, управляемых ползунками пользовательского интерфейса.

Нормаль: распаковка сжатых нормалей
Нормали распаковываются с помощью пользовательской функции материала MF_VAT_UnpackNormal, которая восстанавливает компонент Z из упакованных значений X и Y, затем синхронизируется с помощью Lerp с смешиванием WPO. Здесь зеркально отражена та же цепочка Lerp из раздела WPO, чтобы сохранить нормали согласованными с анимированными позициями.

Волна: волна на стадионе, управляемая GPU
Поведение волны — это визитная карточка системы. Когда срабатывает, зрители по всему стадиону встают последовательно, создавая классический эффект пульсирующей «волны на стадионе». Важно, что это вычисляется полностью в шейдере путём расчёта уникального времени начала для каждого агента относительно его положения на стадионе или его GlobalID.
Коллекция материальных параметров MPC_VAT содержит глобальные переменные: WaveStartTime (когда была запущена волна) и WaveSweepDuration (сколько времени должно занять полное перемещение по стадиону). Каждый экземпляр хранит свой уникальный GlobalID в PerInstanceCustomData[6]. Раздел Wave шейдера использует этот ID для расчёта персонализированного времени срабатывания:
WaveTriggerTime = (GlobalID × WaveSweepDuration) + WaveStartTime
Когда узел Time материала превышает WaveTriggerTime, WaveAnimAlpha интерполируется от 0 до 1, плавно переводя экземпляр из текущей анимации в строку VAT Wave.


Основной цвет
Рандомизация основного цвета гарантирует, что никакие два соседних зрителя не одеты в один и тот же цвет. PerInstanceRandom управляет сдвигом оттенка на диффузной текстуре, а отдельный слот PerInstanceCustomData[3,4,5] хранит цвет рубашки, установленный в Blueprint при появлении, что позволяет процессору устанавливать цвета один раз при появлении, а не пересчитывать их каждый кадр.

Часть 4: Создание экземпляров в Blueprint и логика пользовательского интерфейса
Blueprint CrowdManager
Один актёр BP_CrowdManager считывает таблицу данных и заполняет HISM. Функция создания экземпляров (AddAgentInstances) принимает целевое количество агентов, вычисляет, сколько экземпляров нужно добавить или удалить на основе текущего состояния, и выполняет итерацию по (перетасованному) списку создания экземпляров.
«Перетасованный список создания экземпляров» является ключом к органичному заполнению. Вместо заполнения мест ряд за рядом строки таблицы данных сортируются в перемешанном порядке при инициализации. По мере увеличения ползунка Fill % толпа растёт случайным, рассеянным образом по всему стадиону.




Привязка пользовательского интерфейса
Виджет пользовательского интерфейса использует два типа ползунков: ползунок Spawn #, который напрямую управляет общим количеством агентов, и ползунок Fill %, который контролирует, какой процент созданных агентов отображается. Ползунки состояния анимации (Clap, Yell, Stand) управляют каналами PerInstanceCustomData для смешивания альф.

Раздел привязки пользовательского интерфейса. Сверху: события создания агентов привязаны к ползункам процента заполнения и количества агентов, каждый вызывает AddAgentInstances. Внизу: событие ползунка анимации выполняет итерацию по всем экземплярам агентов и устанавливает соответствующий канал пользовательских данных.
Часть 5: Результаты производительности
Демонстрация в действии
Прежде чем перейти к цифрам: вот как система выглядит на самом деле во время работы.
Система толпы в действии с созданием экземпляров в реальном времени и смешиванием анимации. По мере настройки ползунков Spawn # и Fill % зрители появляются на стадионе органично, а не заполняя места ряд за рядом. Ползунки анимации управляют каналами PerInstanceCustomData для каждого экземпляра, переводя 6 072 зрителя между состояниями Idle, Clap, Yell, Stand и Wave без затрат на анимацию процессора.
VAT против скелетной сетки: реальная стоимость
Чтобы доказать, что подход VAT действительно того стоит, тот же стадион был заселён сравнимым количеством стандартных актёров скелетной сетки, уменьшенных до почти идентичного бюджета полигонов, как у статической сетки VAT, так что сравнение является справедливым. Всё профилирование проводилось на RTX 3070 при 1080p с 90% процентом экрана.
Бюджет сетки для обоих подходов почти идентичен. Статическая сетка VAT имеет 14 130 треугольников/10 063 вершины, а скелетная сетка — 14 073 треугольника/12 292 вершины.
Метрика | Скелетная сетка (6 072 агента) | VAT + HISM (6 072 агента) | Улучшение |
|---|---|---|---|
FPS | 28,37 | 57,31 | в 2 раза быстрее |
Время игры | 34,15 мс | 0,66 мс | в 51,7 раз быстрее |
Время отрисовки | 16,22 мс | 0,74 мс | в 21,8 раз быстрее |
Время GPU | 32,00 мс | 17,51 мс | в 1,8 раз быстрее |
Вызовы отрисовки | 21 369 | 276 | на 77,4 % меньше |
Устранение узкого места в игровом потоке: переход от 34,15 мс к 0,66 мс в игровом потоке фактически «разблокировал» движок. Процессор так усердно работал над вычислением преобразований костей для скелетных сеток, что у него не оставалось ресурсов для логики геймплея, физики или любых других игровых систем.
Эффективность вызовов отрисовки: сокращение количества вызовов отрисовки с 21 369 до 276 — это снижение нагрузки на RHI на 98,7 %. Объединяя 6 072 агента в иерархические экземпляры статических сеток (HISMs), движок отправляет на GPU лишь несколько массивных инструкций вместо тысяч мелких.
Преимущества для GPU через инстансинг: несмотря на то, что текстуры анимации вершин добавляют вычислений в вершинный шейдер, время работы GPU сократилось почти наполовину (с 32 мс до 17,5 мс). Это доказывает, что накладные расходы на управление 6 000 отдельными вызовами отрисовки скелетных сеток фактически замедляли сам GPU, а конвейер инстансинга HISMs значительно более эффективен для обработки аппаратурой.


Сложность шейдера
В Unreal представление сложности шейдера визуализирует стоимость инструкций на пиксель по цвету от зелёного (дешёвые) до розового (дорогие). Когда 6 0072 агента VAT заполняют кадр, вся толпа отображается в зелёной полосе.
Визуализация буфера сложности шейдера с 6 072 активными агентами. Несмотря на пять одновременных выборок анимационных текстур, распаковку ограничивающего прямоугольника, линейные интерполяции, волновое время и изменение цвета для каждого экземпляра, толпа остаётся в «хорошей» зелёной зоне по всей арене.
Ловушка точности текстур 8-бит против 16-бит
Одно решение, которое легко упустить из виду в настройках Houdini VAT ROP, имеет заметные последствия: Растровая глубина. Установка этого параметра в 8-бит вместо 16-битной плавающей запятой приводит к ошибке квантования в сохранённых смещениях позиций, что проявляется в виде дрожания вершин. Всегда используйте 16-бит. Накладные расходы на память текстуры невелики, а визуальная разница очевидна.
Заключение
Переход от скелетных сеток, управляемых процессором, к экземплярам VAT, управляемым GPU, меняет фундаментальный вопрос систем толпы. Он перестаёт быть вопросом о том, сколько персонажей можно себе позволить, и становится вопросом о том, как сделать их реалистичными. Самым удовлетворяющим моментом в этом проекте было наблюдение за тем, как волна впервые распространяется по всему стадиону, когда 6 072 фигуры поднимаются последовательно, управляемые двумя числами, отправленными с процессора, и ничем иным. В этом и заключается обещание технического искусства: превращение холодной математики в коллективные эмоции.
В перспективе потенциал этой системы выходит далеко за рамки ручных слайдеров. Благодаря интеграции динамических реакций на освещение в шейдер, где огни стадиона пульсируют в ответ на энергию толпы, и расширению разнообразия персонажей с помощью общих мешей VAT материалов, она может двигаться в сторону сред, которые не просто плотные, но и глубоко реагирующие. Эта структура прокладывает путь для толп, управляемых живыми, непредсказуемыми игровыми событиями, превращая фоновых зрителей в активных участников цифрового опыта.
В эпоху растущей визуальной сложности самая мощная оптимизация — это не только сохранение кадров. Это создание пространства для жизни.
Ёнжу Чо, технический художник
Автор: Yeonju Cho