C++/Go/Python
Game Developer

Как не стоит генерировать частицы

В результате небольшого рефакторинга системы частиц сломался “рандом”. Как его можно сломать и на что это повлияло?

Тестировщики создали систему частиц со случайным разбрасом и получили следующий результат:

Баг после рефакторинга

Как же могло получится, что есть явно 3 выделенных направления?

Прежде чем фиксить баг, стоит всегда понять, а какое поведение было изначально.

Баг до рефакторинга

Но и тут есть 3 выдленных направления! Хотя если присмотрется, то их больше. Получается, только с увеличением количества частиц можно было увидеть этот баг.

В результате небольшого исследования было найдено изменение, которое проявило эту проблему - в класс самой частицы добавилось новое поле с типом vector3.

Как повлияло добавление нового поля в класс?

Упрощенно, код генерации направления движения частицы выглядит примерно следующим образом:

constexpr int PRE_GENERATED_SIZE = 57;
std::array<Vector3, PRE_GENERATED_SIZE> preGeneratedDirection;

Vector3 GetRandomDirection(const Particle *particle)
{
    uintptr_t partticlePointer = reinterpret_cast<uintptr_t>(particle);
    uint64 particleId = static_cast<uint64>(partticlePointer);

    uint32 clampedIndex = particleId % PRE_GENERATED_SIZE;
    return preGeneratedDirection[index];
}

В игровых движках очень много различных оптимизаций, и тут тоже есть небольшая - массив направлений создан статически, что дает следующие бонусы:

  • исключает необходимость вызова функции random во время генерации частиц:
  • можно записать этот массив и затем воспроизвести в точности в любой среде независимо от генератора (аля seed);

Дебаг показал, что значение clampedIndex всегда равно одному из 3 чисел {0, 19, 38}. А это значит что particleId и PRE_GENERATED_SIZE кратен 19. Выбирать в качестве делителя непростое число - странная идея, так как наличие общих делителей ухудшает распределение в пределах [0, PRE_GENERATED_SIZE]. Скорей всего, автор кода посчитал 57 простым числом. Быстрая проверка с заменой на 59 дает отличный результат - все работает корректно.

здесь могла быть гифка с фиксом, но предется поверить на слово =)

Но почему particleId кратен 19? Частицы выделяются последовательно и указатели будут располагаться на расстоянии размера частицы. Размер частицы до рефакторинга был равен 120 байтам. После добавления vector3 стал 132 байта. Но… 132 ведь не кратно 19! А вот 133 - кратен. Где еще один байт?

А тут уже виновата еще одна оптимизация в геймдеве. Для частиц используется кастомный аллакатор, который выделяет их последовательно в большом блоке памяти. Это нужно для улучшения пространственной локации частиц - в кеш процессора данные загружаются эффективнее и, как результат, снижается количество кэш миссов. Аллокатор выделяет блок размером sizeof(Particle) + short. И этот дополнительный байт используется для создания связанного списка выделенных объектов.

Распределение по статическому массиву направлений:

clampedIndex для расстояния 133 и размера 57. Тут только 3 значения:

>>> sorted([(133*i)%57 for i in range(30)])
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 38, 38, 38, 38, 38, 38, 38, 38, 38, 38]

clampedIndex для расстояния 121 и размера 57. Распределение хоть и лучше, но в распределении все равно есть дыры:

>>> sorted([(121*i)%57 for i in range(30)])
[0, 4, 5, 6, 7, 11, 12, 13, 14, 18, 19, 20, 21, 25, 26, 27, 28, 32, 33, 34, 35, 40, 41, 42, 47, 48, 49, 54, 55, 56]

clampedIndex для расстояния 133 и размера 59 уже дает честное распределение:

>>> sorted([(133*i)%57 for i in range(30)])
[0, 1, 2, 3, 4, 5, 6, 7, 15, 16, 17, 18, 19, 20, 21, 22, 30, 31, 32, 33, 34, 35, 36, 45, 46, 47, 48, 49, 50, 51]