Как не убить FPS: балансировка многопоточной системы частиц — Game Design Radar
← Все посты

Как не убить FPS: балансировка многопоточной системы частиц

13.12.2025
Как не убить FPS: балансировка многопоточной системы частиц

Автор делает 2D космический автобаттлер Ridiculous Space Battles с огромным количеством эффектов. Основной CPU‑бутылочной горлышко — система частиц: тысячи эмиттеров и сотни тысяч частиц. Обновление частиц вынесено в задачеобразный многопоточный движок: главный поток формирует список задач, воркеры забирают их из очереди, главный при необходимости тоже выполняет задачи.

Профилирование в Concurrency Visualizer показывает две проблемы:

  • в обработке частиц участвуют только два рабочих потока;
  • одна задача по обновлению частиц занимает в ~20 раз больше времени других и достаётся главному потоку, превращаясь в узкое место.

Изначальная архитектура частиц

Рендер частиц использует два режима смешивания: Burn (огонь, лазеры, искры) и Normal (дым, обломки). Плюс деление по слою: под кораблями (Below) и над ними (Above). В итоге эмиттеры распределены по четырём спискам:

  • NormalBelow
  • BurnBelow
  • NormalAbove
  • BurnAbove

Для апдейта автор просто отдал каждый из четырёх списков в отдельный поток, считая, что так будет баланс.

Проблема: распределение частиц по этим группам крайне неравномерно. Около 95% всех частиц — в BurnAbove (огонь, искры от обломков, торпеды, дроны, ракеты и т.п.). Фактически почти вся работа оказывалась в одном списке, который обрабатывал один поток (часто главный), что ломало масштабирование.

Новая система: отдельные списки для апдейта

Рендерные списки (4 группы) оставили как есть, но поверх них автор ввёл отдельную систему списков только для обновления:

  • создаётся 8 «балансировочных» списков;
  • при создании эмиттер кладётся в «следующий» список по кругу (round-robin) и запоминает свой индекс списка;
  • при удалении эмиттер убирается из соответствующего списка;
  • фактического delete нет — эмиттеры возвращаются в пул, что усложняет логику.

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

Отладка багов

После внедрения новой системы начались крэши и порча данных. Причина оказалась в отдельной подсистеме дымовых шлейфов (smoke plumes):

  • для них есть свой отдельный апдейт, так как они массовые и используют один и тот же конфиг;
  • эти эмиттеры по ошибке попали в новые балансировочные списки;
  • в многопоточном апдейте эмиттеры шлейфов «удалялись» (возвращались в пул), а затем к ним обращался код подсистемы шлейфов → крэш.

Исправление: исключить дымовые шлейфы из новой балансировочной системы.

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

Отдельная проблема — игра использовала максимум 4 потока из‑за жёстко прописанного лимита (оставшегося после отладки). После замены лимита на 10 потоков Concurrency Visualizer показывает равномерное распределение задач UpdateParticles по 8 воркерам, главный поток берёт только остаток. Теоретически апдейт частиц стал до 8 раз быстрее по сравнению с однопоточным вариантом.

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

Выводы

  • Нельзя полагаться на логические группы (типа режимов смешивания) как на единицы балансировки нагрузки — они могут быть радикально неравномерны.
  • Лучше отделять структуры для рендера и для апдейта: рендерные списки по визуальным признакам, апдейтные — по нагрузке.
  • Round-robin распределение эмиттеров по нескольким спискам даёт простой и рабочий способ выровнять нагрузку между потоками.
  • При реюзе объектов и пуллинге важно строго контролировать, какие подсистемы владеют объектом и когда он может быть возвращён в пул.
  • Проверка хардкодов (лимитов потоков и т.п.) — обязательный шаг при оптимизации и профилировании.
cancel Факт-чекинг
  • "в теории обновление частиц в 8 раз быстрее, чем при однопоточности" — линейное масштабирование в 8x здесь подаётся как почти очевидный результат, хотя в реальности многопоточность даёт сублинейный прирост из‑за накладных расходов (синхронизация, кеш, планировщик, ложное разделение и т.п.). Корректнее говорить, что теоретический максимум — до 8x, но фактический выигрыш обычно меньше и требует измерений.
  • "я просто поменял максимум потоков на 10 и всё заработало" — создаётся впечатление, что увеличение числа рабочих потоков почти автоматически даёт выигрыш. В индустриальной практике это верно не всегда: при 12‑ядерном CPU 10 рабочих потоков плюс главный поток могут уже упираться в планировщик, а при I/O, NUMA, кеш‑конфликтах и прочем рост числа потоков может не давать прироста или даже замедлять код. Утверждение звучит чрезмерно оптимистично без оговорок.
  • "естественно, я дал по одному списку на поток и считал задачу решённой" — подразумевается, что равное количество логических списков даёт балансировку нагрузки. С точки зрения параллельного программирования это спорное допущение: равное число списков не гарантирует равный объём работы, особенно при сильно неравномерном распределении частиц между группами. Это скорее типичная ошибка, чем общепринятая практика.
  • "обновление частиц — один из самых больших потребителей CPU" — подано как универсальный факт. В ряде игр это верно, но в других основную нагрузку могут давать ИИ, физика, анимация, навигация и т.п. Корректнее было бы ограничить утверждение рамками конкретного проекта, а не как общее правило.
sports_esports Упомянутые игры