Cимулируем жизнь 30 млн микробов в браузере


WebGPU даёт прямой доступ к современным возможностям видеокарты: не только к рендерингу, но и к вычислениям общего назначения через compute shaders.
Но насколько далеко это можно довести в обычном браузере? Что произойдёт, если вычисления — это полноценная симуляция с десятками миллионов объектов? Разберемся в этой статье.
Предыстория
Для эксперимента попробуем воскресить симулятор эволюции, написанный много лет назад, и переписать его логику с CPU на GPU. Подробней об этом проекте я рассказывал в другой статье, но читать её необязательно — важные моменты я перескажу.
Мой клеточный симулятор был написан на C# и устроен вполне естественно: двумерная матрица объектов, отдельный список живых клеток и foreach, который на каждом тике вызывал их логику. Клетка смотрела по сторонам, выбирала действие и сразу меняла общий мир. Этого хватало, чтобы на экране возникали популяции, которые борются между собой и эволюционируют.
Веб-версия того симулятора позволяла создавать миры максимум 200 × 200. Попробуем поднять планку до 8192 × 4096. Это около 33 млн ячеек, каждая из которых потенциально может быть занята живой клеткой.
Сначала разберёмся с памятью
Если на каждую из 33 млн позиций выделить всего одно 32-битное число, получится уже 128 MiB. А одного числа недостаточно: нужно где-то хранить возраст клетки, вид, энергию, индивидуальные признаки и ещё промежуточные данные, необходимые во время расчёта следующего состояния мира.
На CPU всё это удобно собрать в один объект типа Cell. Но на таком масштабе привычный ООП-подход становится довольно дорогим. Добавили к каждой позиции ещё одно поле размером 4 байта — потенциально получили ещё 128 MiB..
В GPU-версии я разделил состояние на несколько массивов. В одном хранится основная информация о содержимом ячейки, отдельно лежат энергия, индивидуальные признаки и геном вида. Временные данные одного тика тоже живут в собственных массивах.

Такое разбиение оказалось полезно не только ради экономии памяти. Разным вычислительным проходам нужны разные данные: шейдеру, который меняет энергию, не обязательно видеть всё состояние клетки, а рендереру вообще не нужны временные запросы на перемещение клетки. В результате устройство памяти начинает зависеть от того, как эти данные будут проходить через GPU.
От последовательного тика к параллельному
В старой версии игра просто брала список живых клеток и по очереди вызывала их логику. Клетка смотрела на соседей, выбирала действие и сразу изменяла общий мир. Следующая клетка уже видела результат предыдущего хода.
То есть порядок обхода незаметно становился частью правил симуляции.
На GPU такой подход напрямую не переносится. Тысячи клеток могут обрабатываться одновременно, и нельзя рассчитывать, что одна из них обязательно выполнится раньше другой. Например, две клетки могут одновременно увидеть одну свободную позицию и обе решить туда переместиться.
Поэтому теперь один тик разбит на несколько последовательных стадий.
Сначала клетки только принимают решения и записывают свои Intent — что именно они хотят сделать. При этом само состояние мира ещё не меняется.
Затем появляются Claim — заявки на то, за что клетки конкурируют. Если несколько клеток претендуют на одну позицию, конфликт разрешается отдельно по заранее определённому правилу.
И только после разрешения конфликтов начинается стадия Apply, которая действительно изменяет состояние мира.

Это требует больше промежуточной памяти и нескольких вычислительных проходов, зато результат тика больше не зависит от случайного порядка выполнения тысяч параллельных потоков.
Как показать 33 млн ячеек
К этому моменту мир уже живёт и изменяется на GPU. И тут возникает довольно естественный вопрос: зачем вообще возвращать его на CPU только ради картинки?
Можно было бы после каждого тика копировать состояние обратно в JavaScript и рисовать оттуда. Но при таких размерах сам обмен данными быстро стал бы отдельной проблемой.
По этой причине полной CPU-копии мира в новой версии нет. Рендеринг читает необходимые данные прямо из GPU-буферов, а JavaScript в основном занимается интерфейсом, камерой и отправкой команд.

При сильном отдалении возникает другая проблема: отдельная клетка становится меньше одного пикселя. Рисовать её как самостоятельный объект уже бессмысленно.
Поэтому дальний вид уже строится из агрегированных участков мира. Чем дальше камера, тем меньше значение имеет конкретная клетка и тем важнее общая картина: плотность популяций, ресурсы и распределение видов.
Что получилось
Для проверки я попробовал максимально нагрузить симуляцию и заполнить клетками вообще весь доступный мир.
Даже в таком режиме на моём компьютере симуляция просчитывает примерно 5–10 полных тиков в секунду.
Конечно, 5–10 тиков/с — не универсальная характеристика WebGPU. Скорость зависит от видеокарты, браузера, настроек симуляции и количества живых клеток. Но для меня важнее сам принцип: симуляция на десятках миллионов активных объектов оказывается вполне работоспособной прямо в обычной вкладке браузера.
Итоги
Когда я начинал этот ремейк, главным интересом для меня было понять, насколько большой живой мир вообще можно симулировать прямо во вкладке браузера.
Судя по результату, десятки миллионов объектов — вполне рабочий масштаб. Но для этого недостаточно просто перенести старый код на GPU, приходится перестраивать сами данные и логику вычислений под его модель работы.
Если хочется посмотреть на симуляцию вживую, текущая версия доступна здесь: The Strongest Survives на itch.io.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.