Я сделал физику для VFX-частиц в Unity и выпустил её в Asset Store
Недавно у меня вышел ассет BO VFX Physics для Unity Asset Store.
Это не набор готовых эффектов “10 дымов, 20 искр и 5 магических шаров”. Ассет делает другую вещь: добавляет физику для частиц в Visual Effect Graph — столкновения с окружением, MeshCollider, межчастичные взаимодействия, притяжение, отталкивание, трение, отскок и работу через GPU-буферы.
Изначально я делал это не как продукт, а как внутреннюю систему под свои задачи. Мне хотелось, чтобы VFX-частицы не просто красиво летели по кривой, а реально ощущались частью сцены: отскакивали от стен, скользили по поверхности, сталкивались друг с другом, собирались в группы, разлетались, реагировали на меши и не превращали всё это в тысячи GameObject’ов с Rigidbody.
В итоге задача оказалась намного сложнее, чем “добавить коллизию к частице”.
Почему стандартного VFX Graph оказалось мало
Visual Effect Graph хорош, когда нужно много частиц на GPU. Дым, искры, снег, обломки, магические эффекты, энергетика — всё это его территория.
Но как только хочется физики, начинаются ограничения.
Да, можно сделать простой collision block. Да, можно заставить частицы отскакивать от каких-то базовых поверхностей. Но мне нужна была система, которая может работать не только в стерильной демо-сцене, а в нормальном Unity-проекте:
- частицы должны сталкиваться с Box/Sphere/Capsule Collider;
- должны работать MeshCollider, в том числе невыпуклые;
- частицы должны взаимодействовать друг с другом;
- несколько VFX-инстансов должны жить одновременно;
- эффекты должны нормально запускаться из пула;
- всё должно работать в билде, а не только в редакторе;
- нельзя создавать GameObject/Rigidbody на каждую частицу;
- нельзя каждый кадр гонять данные туда-сюда между CPU и GPU.
Что есть в VFX Graph из коробки
Справедливости ради, в Unity VFX Graph уже есть физика частиц.
Там есть стандартные collision-блоки: Plane, Sphere, AABox, Cone, Depth Buffer, Signed Distance Field. Для многих простых эффектов этого хватает. Если нужно, чтобы дождь падал на плоскость, искры отскакивали от условной коробки, а частицы примерно реагировали на depth buffer камеры — встроенные блоки закрывают задачу.
Но как только пытаешься сделать не демку, а нормальную игровую систему, быстро начинаются ограничения.
Plane/Sphere/Box хороши, пока форма простая. Но реальная сцена редко состоит из одной плоскости и пары сфер. В игре есть MeshCollider, сложная геометрия, несколько объектов рядом, разные масштабы, повороты, runtime-spawn, пуллинг и несколько VFX-инстансов одновременно.
Depth Buffer collision выглядит удобно, но это screen-space решение. Частицы сталкиваются не с настоящей геометрией мира, а с тем, что видит конкретная камера. Отсюда типичные ограничения: зависимость от камеры, проблемы с объектами вне экрана, с тонкой геометрией, с углами, с несколькими камерами, с offscreen-ситуациями. Для красивого эффекта “здесь и сейчас” это нормально. Для физики, которая должна быть частью мира, уже не очень.
Signed Distance Field мощнее, потому что позволяет описывать более сложные формы. Но за это приходится платить подготовкой SDF. Нужно создать/запечь поле, настроить масштаб, качество, границы, обновление, соответствие реальной геометрии. Если объект динамический или сцена собирается во время игры, всё становится ещё сложнее. Для отдельных заранее подготовленных объектов это рабочий путь. Но мне хотелось, чтобы система могла подхватывать обычные Unity Collider’ы и MeshCollider’ы, а не требовала от пользователя вручную готовить SDF под каждую поверхность.
В итоге стандартная физика VFX Graph хороша, когда ты заранее знаешь форму столкновения и готов под неё руками собрать граф. Но если хочется более универсального поведения — “вокруг эффекта есть реальные коллайдеры сцены, частицы должны сами с ними взаимодействовать” — приходится строить свою систему поверх VFX Graph.
Почему это нельзя было сделать просто через внутренние данные VFX Graph
Самое неприятное ограничение VFX Graph для такой задачи — он не даёт нормального публичного API к своему внутреннему буферу частиц.
То есть нельзя просто сказать:
“Unity, дай мне внутренний particle buffer этого VisualEffect. Я хочу получить по определенному particle id - position, velocity, oldPosition, alive state, и хочу заменить тот же position, velocity”.
Для обычного пользователя VFX Graph это даже плюс: граф скрывает всю внутреннюю кухню. Но когда ты пишешь физическую систему поверх VFX, это превращается в проблему.
Мне нужно было, чтобы частицы:
- записывали себя в общий буфер;
- находили соседей через spatial grid;
- понимали, какому VFX-инстансу принадлежат;
- не смешивались с частицами другого эффекта;
- могли читать коллайдеры своей группы;
- могли работать в нескольких одновременно запущенных VisualEffect;
- нормально переживали пуллинг, остановку и повторный запуск.
Если бы был доступ к внутреннему layout’у VFX Graph, часть задачи была бы проще. Но такого API нет. Поэтому пришлось делать собственный слой адресации.
Каждый VFX-инстанс получает InstanceSeed. Каждый VFX asset получает GroupSeed. На CPU я создаю metadata buffer, group buffer и hash-таблицы. На GPU частица берёт свой InstanceSeed, ищет metadata, получает SlotOffset, SlotCount, GroupSeed, настройки grid, local/world transform и только после этого понимает, куда ей писать себя в общем particle buffer.
Фактически пришлось сделать свою мини-систему памяти поверх VFX Graph:
- BO_UnifiedMetadata — метаданные конкретного VFX-инстанса;
- BO_MetadataHash — быстрый поиск metadata по InstanceSeed;
- BO_Groups — данные общей группы эффекта;
- BO_GroupHash — поиск группы по GroupSeed;
- BO_Particles — мой собственный particle buffer;
- BO_GridData — единый буфер spatial grid;
- offsets внутри группы — чтобы разные эффекты не писали друг другу в память.
Снаружи это выглядит как несколько параметров в VFX Graph. Внутри — самоиндексация, hash lookup, offsets, слоты, диапазоны памяти и контроль того, чтобы каждый эффект писал строго в свою область.
Это была одна из главных инженерных болей ассета. Не сама формула столкновения, а необходимость построить инфраструктуру вокруг VFX Graph, потому что VFX Graph не предназначен для того, чтобы сторонний код напрямую управлял его внутренними частицами как массивом данных.
Главная идея ассета
BO VFX Physics работает вокруг нескольких сущностей.
С CPU-стороны система:
- регистрирует VFX-инстансы;
- сканирует коллайдеры вокруг эффекта;
- агрегирует коллайдеры по группам;
- строит данные для MeshCollider;
- управляет GraphicsBuffer;
- передаёт метаданные в шейдеры.
С GPU/VFX-стороны:
- частица пишет себя в общий particle buffer;
- попадает в spatial grid;
- другие частицы могут найти её как соседа;
- world collision block читает буферы коллайдеров;
- mesh collision проходит через BVH;
- particle-particle interaction работает через сетку и ограничения по количеству проверок.
Важный момент: это не замена PhysX (но этим я и вдохновлялся). Это именно VFX-физика. Она нужна там, где хочется большого количества визуальных частиц с физическим поведением, но без цены полноценной физической симуляции на каждый объект.
Частица внутри VFX Graph передаёт в HLSL свой InstanceSeed. По нему GPU ищет metadata в hash-таблице. Из metadata частица узнаёт:
Из-за этого в спавнере пришлось явно отключить автоматический сброс seed через resetSeedOnPlay = false, проверить seed при запуске и, если он равен нулю или уже зарегистрирован, создать новый InstanceSeed на основе runtime entity id самого VisualEffect.
То есть startSeed из “настройки рандома” превратился в ключ всей системы адресации.
И вот тут всплыла неприятная вещь: в билде vfx.startSeed мог оказаться равен 0 при старте.
Почему vfx.startSeed внезапно стал критически важным
Обычно startSeed в VisualEffect воспринимается как настройка рандома. Ну seed и seed: поменял — эффект выглядит чуть иначе.
В моей системе он стал намного важнее. Я использую seed как runtime-идентификатор VFX-инстанса.
Причина простая: раз VFX Graph не отдаёт мне свой внутренний particle buffer и не даёт нормального API для адресации частиц снаружи, мне нужно было самому построить мост между C#-частью и GPU-частью. Этим мостом стал InstanceSeed.
Частица внутри VFX Graph передаёт в HLSL свой InstanceSeed. По нему GPU ищет metadata в hash-таблице. Из metadata частица узнаёт:
- какой диапазон BO_Particles принадлежит её эффекту;
- какой GroupSeed использовать;
- где лежат настройки spatial grid;
- включены ли collisions;
- какой SlotOffset и SlotCount;
- нужно ли переводить позицию из local space в world space;
- какие лимиты использовать для neighbour checks.
И вот тут всплыла очень неприятная проблема: в билде vfx.startSeed при старте мог быть равен 0.
Для обычного VFX это, возможно, не катастрофа. Для моей системы это ломает адресацию. Нулевой seed нельзя использовать как нормальный уникальный ключ в hash-таблице. А если несколько эффектов получают одинаковый seed, они начинают конфликтовать: частицы пишут в чужие диапазоны, читают чужие metadata, попадают не в ту группу и вся физика выглядит как случайная поломка.
Из-за этого в спавнере пришлось явно отключить автоматический сброс seed через resetSeedOnPlay = false, проверить seed при запуске и, если он равен нулю или уже зарегистрирован, создать новый InstanceSeed на основе runtime entity id самого VisualEffect.
То есть startSeed из “настройки рандома” превратился в ключ всей системы адресации.
Это был один из тех багов, которые очень сложно искать: визуально кажется, что сломалась физика частиц, BVH, grid или collision response. А реальная причина — эффект в билде стартовал с seed 0, из-за чего вся GPU-адресация разваливалась.
Почему пришлось делать группы эффектов
Следующая проблема: один эффект — это просто. Несколько эффектов — уже нет.
Если каждый VisualEffect держит полностью свои буферы, это неудобно и дорого. Если всё сложить в один общий буфер без структуры, эффекты начинают мешать друг другу. Поэтому появилась идея physics group.
Группа строится вокруг VFX asset. Несколько инстансов одного эффекта могут разделять одну группу, общую сетку и общий набор данных, но при этом каждый инстанс получает свой диапазон слотов в particle buffer.
На практике это выглядит так:
- каждый VFX instance имеет InstanceSeed;
- каждый VFX asset имеет GroupSeed;
- instance хранит SlotOffset и SlotCount;
- group хранит offsets для grid, colliders, mesh triangles и BVH;
- на GPU есть hash-таблицы для поиска metadata и group по seed.
То есть частица в шейдере не получает прямую ссылку на C#-объект. Она получает InstanceSeed, по нему ищет metadata, из metadata получает group, а дальше уже понимает, где в общих буферах находятся её данные.
Это, наверное, одна из самых важных внутренних частей ассета. Снаружи пользователь видит несколько параметров в VFX Graph, а внутри нужно было построить маленькую систему адресации поверх GPU buffers.
Единый буфер — и почему его пришлось резать на части
Одна из больших инженерных болей — организация памяти.
Сначала кажется: ну сделаю буфер частиц, буфер коллайдеров, буфер сетки, и всё. Потом оказывается, что каждый тип данных имеет свои правила жизни.
Для частиц нужен общий particle buffer. В нём на каждую частицу хранится несколько float4:
- position + radius;
- velocity + mass;
- oldPosition + frame index.
Для spatial grid нужен другой буфер. Причём сетка внутри устроена не просто как массив ячеек. Для каждой группы там есть:
- head-часть;
- frame-часть;
- next-часть.
Head хранит начало linked list в ячейке. Next хранит цепочку частиц внутри ячейки. Frame нужен для lazy reset ячеек, чтобы не чистить всю сетку каждый кадр на CPU.
И вот здесь появляется та самая “разбивка стека на разные части буфера”. По сути один общий массив нужно аккуратно нарезать на диапазоны для разных групп и разных типов данных. У каждой группы свои offsets:
- HeadOffset;
- FrameOffset;
- NextOffset;
- NumCells;
- CellMask;
- MaxParticles.
Если ошибиться хотя бы на один offset, эффект может работать почти нормально, а потом на высокой плотности частиц внезапно начать читать чужие ячейки или чужие next-ссылки.
Почему нельзя просто очистить сетку каждый кадр
Один из простых вариантов — каждый кадр полностью очищать grid buffer. Но это дорого и плохо масштабируется.
Вместо этого используется lazy initialization на GPU.
У каждой ячейки есть frame token. Когда первая частица в кадре хочет записаться в ячейку, она проверяет, была ли эта ячейка уже инициализирована в текущем кадре. Если нет — через atomic операции сбрасывает head в -1 и ставит актуальный frame token. Остальные частицы уже идут по быстрому пути.
Это маленькая деталь, но она сильно влияет на практическую работу системы. Потому что VFX-частиц может быть очень много, и лишняя полная очистка сетки превращается в постоянный налог за кадр.
Межчастичные взаимодействия
Particle-particle interaction работает через spatial grid.
Каждая частица:
- вычисляет свою ячейку;
- записывает себя в linked list этой ячейки;
- при расчёте взаимодействия смотрит соседние ячейки;
- проверяет ограниченное количество соседей;
- применяет столкновение, трение, отскок, притяжение, отталкивание.
Самое важное здесь — ограничения.
Если дать частицам проверять всех со всеми, это быстро превращается в O(N²) и умирает. Поэтому есть параметры вроде:
- BO_GridCellSize;
- BO_MaxGridSteps;
- BO_MaxNeighborChecksPerParticle;
- BO_InteractionUpdateIntervalFrames.
Это не просто “настройки для красоты”. Это реальные предохранители, без которых любой плотный эффект может уничтожить производительность.
Пользователь видит это как параметры в спавнере. Но внутри это способ держать систему в рамках: сколько соседей читать из ячейки, сколько всего проверок делать на частицу, как часто вообще применять межчастичную фазу.
MeshCollider и BVH
Отдельная большая боль — MeshCollider.
С простыми коллайдерами всё относительно понятно:
- SphereCollider — центр и радиус;
- BoxCollider — OBB;
- CapsuleCollider — ось, радиус, высота;
- Plane/Quad — плоскость и размеры.
Но MeshCollider — это треугольники. А если проверять каждую частицу против всех треугольников меша, производительность закончится сразу.
Поэтому для MeshCollider я строю BVH — Bounding Volume Hierarchy.
Схема примерно такая:
- Считываются вершины и индексы меша.
- Треугольники фильтруются: выкидываются вырожденные.
- Для каждого треугольника считаются bounds, центр, нормаль и дополнительные данные.
- Строится BVH-дерево.
- Треугольники переупорядочиваются так, чтобы leaf-ноды ссылались на непрерывные диапазоны.
- BVH и triangle data упаковываются в GPU buffers.
- В HLSL обход BVH идёт через стек фиксированного размера.
У BVH тоже было много нюансов.
Например, leaf-нода должна ссылаться на диапазон треугольников. Но после переупорядочивания треугольников нужно следить, чтобы индексы leaf-ноды указывали уже на новый порядок, а не на старый. Иначе всё почти работает, но часть треугольников теряется, появляются просачивание, частицы проходят сквозь меши.
Ещё одна тонкость — размер стека обхода BVH в HLSL. Он фиксированный. Если дерево стало глубже, чем ожидалось, обход начинает терять ветки. В итоге стек пришлось увеличивать, потому что после исправления порядка leaf-ноды стали корректнее, но средняя глубина дерева немного выросла.
Это хороший пример проблемы, которую сложно красиво показать. На видео просто “частицы больше не проваливаются сквозь меш”. А внутри — BVH layout, leaf encoding, reordering, offsets, стек обхода и куча проверок на граничные случаи.
Почему MeshCollider не требует Read/Write
Отдельно пришлось решать вопрос с доступом к данным меша.
В Unity часто можно упереться в то, что mesh data недоступны без включённого Read/Write. Для ассета это плохое требование: пользователь импортирует пакет, а потом должен руками включать Read/Write на своих мешах, иначе физика не работает.
Я пошёл другим путём: данные читаются через graphics buffers Unity. Для меша выставляются raw targets на vertex/index buffer, затем через GetVertexBuffer и GetIndexBuffer читаются позиции и индексы.
Это позволило не требовать Read/Write на импортированных мешах. Но взамен пришлось аккуратно обрабатывать:
- stride vertex buffer;
- offset позиции;
- 16-bit и 32-bit индексы;
- lifetime graphics buffers;
- кэширование parsed mesh data;
- очистку при смене сцены и завершении приложения.
То есть опять: снаружи пользователь видит “MeshCollider работает”. Внутри — отдельная подсистема чтения и кэширования геометрии.
Кэширование мешей
BVH строить дорого. Особенно если MeshCollider тяжёлый.
Поэтому данные меша кэшируются. Кэш хранит:
- локальные треугольники;
- precomputed triangle data;
- BVH nodes;
- статистику cache hits/misses;
- примерный memory footprint.
Ключ кэша учитывает сам mesh и параметр BVHNodesPerMesh, потому что при другом лимите BVH дерево может получиться другим.
Это важно для сцен, где один и тот же меш используется много раз. Например, несколько одинаковых астероидов, камней, объектов окружения. Не хочется каждый раз заново парсить меш и строить BVH, если можно переиспользовать уже подготовленные данные и только применить трансформацию объекта.
Коллизия с мешем на GPU
В HLSL MeshCollider работает через BVH traversal.
Частица переводится в local space меша, радиус тоже пересчитывается с учётом масштаба. Потом идёт проверка против root AABB, потом обход дерева.
Для leaf-ноды проверяются треугольники. Там есть две важные части:
- static penetration: если частица уже внутри/рядом с треугольником;
- swept sphere test: если частица быстро летела и могла проскочить поверхность между кадрами.
Без swept-проверки быстрые частицы начинают туннелить. Без static correction частица может застрять внутри стенки или долго выталкиваться наружу. Поэтому пришлось совмещать оба подхода и выбирать лучший hit: swept hit имеет приоритет как защита от туннелинга, потом выбирается ближайшее столкновение.
Для VFX это особенно важно. Частицы часто маленькие, быстрые и живут недолго. Если физика нестабильна хотя бы на пару кадров, это сразу видно как “искры проходят сквозь стену”.
Управление GraphicsBuffer
Ещё одна скучная, но важная часть — lifecycle буферов.
Нельзя просто создать GraphicsBuffer и забыть. Нужно:
- увеличивать ёмкость, когда частиц стало больше;
- не пересоздавать буфер каждый кадр;
- уметь уменьшать буфер, но не сразу, чтобы не было resize-дёрганья;
- чистить буферы при остановке;
- ставить dummy buffers, чтобы шейдеры не читали null;
- освобождать ресурсы при смене сцены;
- не ловить leaked GraphicsBuffer в редакторе.
В ассете для этого есть отдельная политика ёмкости. Буфер растёт до next power of two, но уменьшается только после cooldown и периода under-utilization. Это сделано, чтобы при пульсирующих эффектах буферы не прыгали туда-сюда.
На практике это не самая заметная часть ассета, но именно она отличает рабочий runtime-инструмент от демки, которая живёт до первого перезапуска сцены.
Почему это оказалось сложнее, чем казалось
Самое сложное в BO VFX Physics — не одна конкретная формула столкновения.
Сложность была в том, что пришлось связать в одну систему много разных уровней Unity:
- Visual Effect Graph;
- VFX Spawner Callbacks;
- Custom HLSL blocks;
- GraphicsBuffer;
- глобальные shader buffers;
- hash lookup по seed;
- spatial grid;
- MeshCollider;
- BVH;
- Burst/Jobs для построения acceleration data;
- runtime pooling;
- Editor vs Build поведение;
- очистку ресурсов;
- поддержку HDRP/URP samples;
И почти каждый слой мог сломаться неочевидно.
Особенно неприятны баги формата “в редакторе всё работает, а в билде нет”. С startSeed == 0 это было именно так. Визуально это выглядело как поломанная физика, но причина была не в физике, а в идентификации VFX-инстанса.
Что получилось в итоге
Сейчас BO VFX Physics умеет:
- столкновения VFX-частиц с Box/Sphere/Capsule Collider;
- поддержку MeshCollider через BVH;
- межчастичные столкновения;
- трение и отскок;
- притяжение и отталкивание;
- force-to-point поведение;
- spatial grid для поиска соседей;
- работу нескольких VFX-инстансов через группы;
- local/world space режимы;
- автоматическое управление GPU buffers;
- debug-визуализацию коллайдеров и grid coloring;
- HDRP и URP samples.
При этом каждая частица не является GameObject, не получает Rigidbody и не живёт на CPU как отдельная физическая сущность. Это именно GPU/VFX-подход.
Ограничения
Это не универсальный физический движок.
BO VFX Physics не заменяет PhysX, Havok или полноценную симуляцию твёрдых тел. Это инструмент для realtime VFX, где нужно сделать частицы физически убедительнее, но сохранить производительность и масштабируемость.
Есть ограничения:
- сканируются только коллайдеры в заданном радиусе;
- первое построение BVH для тяжёлого MeshCollider может занять время;
- качество межчастичных взаимодействий зависит от размера grid cell и лимитов соседей;
- на очень плотных эффектах нужно настраивать BO_MaxGridSteps и BO_MaxNeighborChecksPerParticle;
- результат, как и у многих GPU/VFX-симуляций, не стоит воспринимать как строгий deterministic physics.
Но для своей задачи — физики визуальных частиц — это даёт гораздо больше контроля, чем обычный VFX Graph из коробки.
Зачем я это выпустил
Изначально я делал эту систему для себя. Мне хотелось, чтобы эффекты в моих проектах выглядели более физически, более “встроенными” в мир, а не просто наложенным слоем частиц.
Потом стало понятно, что сама проблема довольно общая. В Unity много кто сталкивается с тем, что VFX Graph мощный, но как только хочется нормального взаимодействия с окружением и другими частицами, начинается танцы с бубном.
Я довёл систему до ассета: добавил готовые VFX-блоки, параметры спавнера, документацию, samples для HDRP/URP, debug tools и упаковку под Asset Store.
Ассет уже доступен в Unity Asset Store:
Буду рад фидбеку, вопросам и идеям, какие примеры добавить в следующих обновлениях.