GPU-система частиц на HLSL: как перешагнуть миллион частиц
Я перенёс состояние частиц полностью на GPU с помощью StructuredBuffer и DrawProceduralIndirect: от 40 тысяч до миллиона — и замерил, чего это стоило.
Кадр, который рассыпался на сорока тысячах
В экшен-прототипе, над которым я работал в прошлом году, на экране могло одновременно оказаться до тридцати взрывов. Когда система частиц на CPU доходила до сорока тысяч живых частиц, профайлер показывал 11,4 мс в главном потоке. Бюджет кадра у нас был 16,6 мс, и в эту цифру ещё не входили игровая логика, анимация и физика.
Куда уходит время, было очевидно. Каждый кадр мы проходили по сорока тысячам структур Particle, обновляли позицию и скорость, а затем копировали те же данные в вершинный буфер. Один только вызов Mesh.SetVertexBufferData стоил 2,1 мс. Burst и Job System опустили часть с обновлением до 3,8 мс, но копирование осталось ровно там, где было.
Первой попыткой было просто уменьшить число частиц: я вдвое сократил количество на эффект. Время кадра упало до 7,9 мс, но взрывы стали выглядеть жидкими, и художники возразили — справедливо. Урезать количество — не решение, а признание того, что задача победила.
Настоящая проблема была не в стоимости вычислений, а в данных, которые ездили между CPU и GPU. Если позицию частицы читает только GPU, нет ни одной причины держать эту позицию в памяти CPU. Я перенёс всю систему на GPU через compute shader на HLSL; дальше — технические подробности этого переезда и полученные цифры.
Хранение состояния внутри StructuredBuffer
Состояние частицы живёт в одной struct: float3 pos, float3 vel, float life, uint seed. Это 32 байта, так что добавлять выравнивающее заполнение не пришлось. Миллион частиц — 32 МБ; поскольку я делаю ping-pong, два буфера вместе занимают 64 МБ VRAM. Поле seed записывается один раз при рождении и делает случайность частицы детерминированной на всю её жизнь.
Читаю через StructuredBuffer<Particle>, пишу через RWStructuredBuffer<Particle>. Читать и писать один и тот же буфер в одном dispatch — неопределённое поведение, потому что никто не гарантирует порядок выполнения thread groups. В ParticleSystemGPU.cs я каждый кадр меняю местами две ссылки на буферы; память не копируется, просто две переменные меняются местами.
Держать структуру в 32 байтах окупается измеримо. Ради эксперимента я добавил поле half4 color, вырос до 40 байт — и ядро обновления на RTX 3060 подскочило с 0,82 мс до 1,19 мс. Цвет и так выводился из времени жизни, так что таскать его в буфере не давало ничего. На GPU узкое место обычно не арифметика, а пропускная способность памяти.
Выделяю память через GraphicsBuffer, а не ComputeBuffer. Работают оба, но GraphicsBuffer позволяет пометить одно и то же выделение и как цель compute, и как аргумент непрямой отрисовки. Использовать одну область памяти для двух целей быстрее, чем держать вторую копию, и это убирает целый класс ошибок.
Почему я беру 64 или 128 потоков на группу
Ядро начинается со строки [numthreads(128, 1, 1)], потому что железо и так работает волнами. У NVIDIA warp — это 32 потока, у AMD wave — 64. Если размер группы не кратен этому числу, часть последней волны крутится вхолостую, и эти потери измеримы.
Я замерил одно и то же ядро на миллионе частиц при четырёх размерах: 1,41 мс на 32 потоках, 0,91 мс на 64, 0,82 мс на 128 и 1,05 мс на 256. Тридцать два — плохо, потому что фиксированная цена на группу — проверка границ, чтение constant buffer — повторяется слишком часто. Двести пятьдесят шесть — плохо, потому что моё ядро использует 40 регистров, а с ростом регистрового давления на один SM помещается меньше одновременных групп.
Ёмкость редко кратна размеру группы, поэтому первая строка ядра — if (id.x >= _Capacity) return;. Забудете её — последняя группа запишет за границу буфера; в Windows обычно получаешь молча неверный результат, а иногда сброс драйвера по TDR. Число групп для dispatch считаю как Mathf.CeilToInt(capacity / 128f), и округление ёмкости вверх до кратного 128 делает эту проверку почти бесплатной.
Миллион частиц группами по 128 — это 7813 групп, далеко ниже предела в 65535 по одной оси, так что раскладывать по второму измерению мне не понадобилось. Примерно после четырёх миллионов в этот потолок вы упрётесь и придётся задействовать id.y. Заранее я это писать не стал: обобщение под случай, которого у меня нет, делало ядро нечитаемым.
Переработка мёртвых частиц через append/consume
Частица, у которой кончилась жизнь, должна передать свой слот новорождённой. Делаю это через AppendStructuredBuffer<uint> _DeadList: умирающая частица добавляет туда собственный индекс. Ядро спавна забирает индексы из того же списка через ConsumeStructuredBuffer<uint>. Так мне вообще не нужен цикл перебора в поисках свободного слота.
Append и consume опираются на атомарный счётчик, поэтому каждый вызов встаёт в очередь за остальными. Если все частицы умирают в одном кадре, этот счётчик становится узким местом: в синтетическом тесте, где я убивал всё разом, ядро выросло с 0,82 мс до 1,26 мс. В реальных сценах смерти растянуты во времени, и разница осталась ниже 0,05 мс, так что я ничего не трогал.
Ядро спавна выполняется отдельным dispatch, до ядра обновления. При обратном порядке новорождённые частицы успевали обновиться в том же кадре и уезжали на кадр вперёд; в движении это незаметно, но в центре каждого взрыва оставалась небольшая дырка. Количество частиц для спавна передаю с CPU одним int, ведь это число всё равно определяет игровая логика.
Здесь две классические ловушки. Первая — создать буфер без флага ComputeBufferType.Append: шейдер компилируется, работает, счётчик остаётся нулевым, и никакого сообщения об ошибке вы не увидите. Вторая — сбрасывать счётчик не в том месте: SetCounterValue(0) я вызываю только на буфере живых индексов, каждый кадр, прямо перед dispatch. Примените тот же вызов к списку мёртвых — сотрёте накопленные свободные слоты, и больше ничего не родится.
DrawProceduralIndirect: никогда не возвращаться на CPU
GPU знает, сколько частиц живо; CPU — нет. Запросить это число через GetData значит дождаться, пока опустеет очередь команд: по моим замерам одно обратное чтение добавляло к кадру 4–6 мс. Поэтому я рисую через Graphics.DrawProceduralIndirect, и счётчик вообще не заглядывает на CPU.
Буфер аргументов — это четыре значения uint: число вершин, число экземпляров, стартовая вершина, стартовый экземпляр. Каждый кадр я вызываю GraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4), чтобы скопировать счётчик живых во вторую ячейку. CPU понятия не имеет, какое там число; он лишь пишет в очередь команду копирования.
На стороне вершин меша тоже нет. Индекс частицы и номер угла я вывожу из SV_VertexID и собираю quad прямо в вершинном шейдере, привязывая туда _ParticlesOut и _AliveIndices как StructuredBuffer. Привязка вершинных буферов, индексные буферы, обновление мешей — всё это исчезает.
Вот измеренная разница. Система на CPU тратила 11,4 мс главного потока на сорока тысячах частиц; система на GPU тратит 0,82 мс на compute, 1,6 мс на отрисовку и 0,05 мс главного потока на миллионе. В двадцать пять раз больше частиц и примерно в восемь раз меньше времени кадра. Что важнее, CPU освободился, и этот бюджет мы отдали ИИ.
Ловушки синхронизации и с чего начинать
GroupMemoryBarrierWithGroupSync() синхронизирует только собственную группу. Синхронизации между группами внутри одного dispatch не существует, и именно это сильнее всего сбивает с толку при переходе на GPU. Если соседние частицы должны читать друг друга — столкновения, стайное поведение, сетка соседства, — этот шаг обязан стать вторым dispatch.
Вторая ловушка — порядок. Порядок внутри _AliveIndices меняется каждый кадр, потому что никто не гарантирует, какая группа закончит первой. Нарисуйте частицы с альфа-смешиванием в таком порядке — и картинка будет мерцать от кадра к кадру; заметил это не я, всё было очевидно на замедленной записи, которую прислал QA. Выходов два: сортировать по глубине на GPU (bitonic sort у меня показал 0,9 мс на миллионе элементов) или перейти на аддитивное смешивание. Я выбрал второе, поскольку большинство эффектов и так были аддитивными.
Привычки отладки тоже придётся поменять. Ни точек останова, ни Debug.Log. Я завожу отдельный RWStructuredBuffer<float4>, пишу туда промежуточные значения, которые вызывают подозрения, и читаю его обратно только когда ловлю баг. Это чтение стоит те же 4–6 мс, поэтому живёт внутри блока #if DEBUG_PARTICLES.
Не пытайтесь перенести всю систему разом. Возьмите сначала один эффект — у меня это были трассы пуль, — переведите его на GPU с буфером фиксированной ёмкости, замерьте dispatch в RenderDoc и только потом добавляйте список мёртвых и непрямую отрисовку. Задавать миллион ёмкости сразу тоже незачем: измерьте реальный пик живых частиц в сцене и возьмите вдвое больше — тогда и VRAM, и время обновления окажутся на своих местах.