Автор разбирает, как построить высокопроизводительную bullet-hell систему (на примере Unity), способную обрабатывать тысячи пуль без просадок FPS. Основные проблемы — не «мало гигагерц», а работа с памятью: частые аллокации и кэш-промахи.
Проблема 1: Аллокации и пуллинг объектов
Стандартный подход с Object.Instantiate() и Object.Destroy() создаёт и уничтожает управляемые объекты C# и нативные ресурсы движка. Для короткоживущих пуль это приводит к постоянным аллокациям и нагрузке на GC.
Решение — пуллинг: заранее создать скрытый пул пуль и при спавне брать неиспользуемый объект, активировать и позиционировать его, а при удалении — деактивировать и возвращать в пул.
Автор показывает минималистичную реализацию на одном поле next в PooledObject. Пул реализован как односвязный список:
- префаб хранит ссылку на голову списка свободных объектов;
- каждый свободный объект указывает на следующий свободный;
- активные объекты указывают обратно на префаб, чтобы знать, в какой пул вернуться.
В итоге создание и освобождение пуль сводится к нескольким присваиваниям, а после стабилизации размера пула новые аллокации не происходят.
Проблема 2: Кэш, контекст-свитчинг и Data-Oriented Design
Даже с пуллингом профайлер показывает значимые затраты на Update() пуль. Основные виновники:
- Physics.Raycast — проверка столкновений;
- установка Transform.position — обновление позиции в сцене.
Низкая производительность связана с кэш-промахами CPU. Память делится на медленную DRAM и быструю кэш-память (SRAM). При обращении к данным вне кэша происходит дорогой перенос данных, что блокирует конвейер процессора.
Инструкции и данные
Во время апдейта пуль затрагиваются четыре области памяти:
- машинный код (ICACHE);
- данные геймплейных скриптов (C# объекты);
- данные PhysX для коллизий (C++ куча);
- данные трансформов (нативные структуры движка).
Перемежающийся код (много разных Update() по чуть-чуть) вызывает частые ICACHE-промахи. Автор показывает, что последовательная обработка одного типа работы (сначала все Foo, потом все Bar, затем Baz) лучше, чем постоянное переключение между ними.
Один BulletSystem вместо тысячи Update()
Рекомендуется перейти от отдельного Update() на каждом снаряде к единой системе:
class BulletSystem { Bullet[] bullets; int bulletCount; void Update() { float dt = Time.deltaTime; for (int it = 0; it < bulletCount; ++it) { // обновление всех пуль } } }Дополнительно:
- использовать структуры (struct), а не классы, чтобы данные были размещены последовательно в памяти без лишней индирекции;
- при необходимости перейти от «массива структур» к «структуре массивов» (SoA), разделив данные на параллельные массивы позиций, скоростей и т.п., чтобы ещё сильнее улучшить локальность данных.
Unity Job System и разнесение данных по потокам
Даже после рефакторинга остаются кэш-промахи из-за вызовов Physics.Raycast и Transform.position, которые обращаются к большим независимым структурам данных. Решение — использовать Unity Job System (доступен с 2017 года) и разделить задачи по потокам:
- в Update() планировать (schedule) джобы для обновления трансформов и физических запросов;
- в LateUpdate() дожидаться завершения джобов и обрабатывать результаты (хиты, удаление пуль и т.п.).
Unity предоставляет:
- RaycastCommand — для пакетных физических запросов;
- IJobParallelForTransform — для параллельного обновления трансформов.
Автор показывает укороченный пример: в Update() создаётся и планируется BulletTransformJob и батч RaycastCommand.ScheduleBatch, а в LateUpdate() вызываются Complete() для обоих джобов и обрабатываются попадания, удаляя пули из массива обменом с последним элементом.
В стресс-тестовой сцене такая архитектура даёт более 100 FPS даже в редакторе. Автор отмечает, что можно дополнительно оптимизировать рендеринг (упростить объекты пуль, перейти на GPU Instancing вместо отдельных MeshRenderer), но уже на этом этапе система достаточно быстрая и предсказуемая.
Выводы
- Для тысяч пуль критичны не вычисления, а работа с памятью: избегайте частых аллокаций и кэш-промахов.
- Используйте пуллинг с минимальными структурами (например, односвязный список через одно поле) вместо Instantiate/Destroy.
- Объединяйте логику в один BulletSystem, храните состояние в массивах структур/SoA для лучшей локальности данных.
- Разносите тяжёлые подсистемы (физика, трансформы) по джобам Unity (RaycastCommand, IJobParallelForTransform), собирая результаты в LateUpdate().
- Планируйте архитектуру bullet-hell заранее под батч-обработку и data-oriented подход, чтобы не упереться в неустранимые узкие места позже.
- Утверждение: «Your Nintendo Switch has four 1.02 GHz ARM Cortex-A57 CPU cores, each one 500x more powerful than the computer which sent us to the moon». Сравнение производительности с «компьютером, который отправил нас на Луну», и тем более численная оценка «500x» — крайне грубая и упрощённая. Производительность нельзя корректно свести к частоте и одному коэффициенту: архитектура, набор инструкций, объём памяти, специализированные блоки и т.п. сильно меняют картину. Число «500x» выглядит как риторическое преувеличение, а не как результат реального измерения.
- Утверждение: «Once the pool size stabilizes we no longer have to allocate or deallocate any memory for as long as the scene continues to run». Это чрезмерное обобщение. В реальных условиях движок и рантайм могут продолжать выполнять внутренние аллокации (например, служебные структуры, массивы, списки, логирование, системы анимации, физики и т.д.), даже если сами пули берутся из пула. Корректнее говорить, что пул минимизирует аллокации, связанные с созданием/уничтожением пуль, а не полностью устраняет все аллокации в сцене.
- Утверждение: «When the CPU attempts to accesses uncached memory it’s called cache miss, which triggers a system interrupt to copy the data from main memory to the cache». Описание механизма слишком упрощено и частично некорректно. Кэш‑промах не обязательно «триггерит системное прерывание» в смысле программного interrupt; подгрузка из памяти обычно обрабатывается аппаратно (аппаратный контроллер кэша, prefetcher, контроллер памяти), без явного системного прерывания на уровне ОС. Для геймдев‑аудитории это допустимое упрощение, но как буквальное описание работы CPU оно спорно.
- Утверждение: «Cache hits are fast, but misses are slow – dozens or even hundreds of cycles. E.g. you could calculate several trig functions or multiply several matrices together during one single cache-miss». Пример с «несколькими тригонометрическими функциями» и «несколькими матрицами» как гарантированно укладывающимися в один кэш‑промах — спекулятивен и не универсален. Стоимость тригонометрии и матричных операций сильно зависит от реализации (аппаратная/программная), размера матриц, оптимизаций компилятора и т.п. В целом идея «кэш‑промах очень дорогой» верна, но конкретное сравнение с таким количеством вычислений — не подтверждённый факт, а иллюстративное преувеличение.
- Утверждение: «For a long time this was an intractable problem with Unity, but in 2017 the Job System was introduced to address batch-processing like this». Формулировка «intractable problem» чрезмерно категорична. До появления Job System и DOTS задачи батч‑обработки и оптимизации кэша в Unity решались разными приёмами (свои менеджеры, нативные плагины, ECS‑подобные структуры, ручной мультипоточность через C# Threads/Tasks и т.п.). Job System упростил и формализовал подход, но говорить, что проблема была «неразрешимой», некорректно — это риторическое преувеличение, а не отражение индустриальной практики.