AI‑Evolution: перенёс симулятор цифровых сущностей с React + Python на Unity

В прошлом посте я писал о том что создаю ИИ-песочницу на React + Python. С тех пор многое обдумал и многое переосмыслил и все-таки пришел к мысли, что выбрал неудачный стек для такого проекта. Напомню для контекста: я занимаюсь разработкой эволюционного симулятора, где агенты (скажем так, цифровые животные) будут бегать по полю, жить и взаимодействовать с окружением не на основе жёсткого дерева условий («если-то»), а под управлением нейросетей.
То есть вместо банального скрипта вроде «если увидел куст — беги к кусту», мозг сущности оценивает всё, что находится перед её глазами: расстояние, окружение, текущий уровень голода. На основе этих факторов сеть сама выдаёт управляющий сигнал — в какую сторону повернуться и с какой скоростью двигаться к объекту.
Маленький дисклеймер: Эта статья — не туториал, а хроника моих личных ошибок и обучения. Я не считаю себя экспертом в нейросетях или в упомянутых далее инструментах. Воспринимайте это как описание поиска пути, встреченных ошибок и принятия решений.
Эпизод I: Архитектурный тупик веб-стека (React + Python)
Первоначально для проекта я выбрал связку React + Python. Просто потому, что с этими инструментами я хорошо знаком по своей основной работе. Относительно быстро набросав игровое поле на React + HTML5 Canvas, я подключил Python с NumPy для вычислительного бэкенда, где на чистых матрицах считаются простенькие нейросети. Обмен данными между фронтендом и бэкендом настроил через WebSockets с частотой дважды в секунду. Собрать такой рабочий прототип оказалось несложно, даже с учётом того, что с Python до этого я сталкивался только в учебных целях.

Но что меня сразу напрягло (хотя поначалу я предпочитал об этом не думать): абсолютно любой «чих» физики мира приходилось рассчитывать вручную. Поле зрения агента, тригонометрическая проверка, находится ли каждый конкретный куст в зоне видимости, затем пространственное хэширование ради хоть какой-то оптимизации... Не спорю, эти задачи заставили напрячься и вспомнить школьную геометрию. Но делать всё это с нуля ради отрисовки обычных кружочков в браузере — откровенный перебор.
Вдобавок к багам в собственных математических расчетах началась классическая веб-борьба за производительность интерфейса и Canvas. В браузере при росте симуляции логика интерфейса (статистика, графики) начинает воевать с отрисовкой за ресурсы процессора, а Event Loop забивается. Также при любом баге или недосмотре в передаче данных между фрнтом и бэком был велик риск, что приложение просто зависнет. Стало очевидно: архитектура «клиент-сервер» через WebSockets для локальной песочницы в реальном времени — это стратегическая ошибка.
Эпизод II: Почему Unity + C#?
Опыта в других языках у меня практически нет. Когда-то давно я изучал Java, а от попыток подступиться к C++ до сих пор ловлю «вьетнамские флешбеки» из-за указателей, ссылок и ручного управления памятью (скорее всего еще сыграл тот факт, что си я изучал будучи старшеклассником и просто-напросто не смог понять всей его глубины). Немного поразмыслив и полазив по интернету, в качестве нового фундамента я выбрал Unity + C#.

В геймдеве я полный ноль, но, в конце концов, я и занялся этим пет-проектом, чтобы учиться новому, а не бесконечно повторять то, что уже умею. Почему именно Unity? Как минимум потому, что три главные вещи доступны здесь «из коробки»:
1. Физика твердых тел и коллизии. Больше никакой тригонометрии вручную — движок сам понимает, когда сущность наступила на куст.
2. Встроенные лучи (Raycasts). Идеально закрывают задачу имитации секторного зрения агентов.
3. Независимый рендеринг GUI. Новый инструмент UI Toolkit (с привычным Flexbox и аналогом CSS-стилей) в перспективе избавит от просадок FPS при выводе сложных графиков статистики эволюции.

То, что язык С# компилируемый, строгий и типизированный оказалось плюсом после динамического Python и JS (иронично, но я думал совершенно противоположное, когда впервые сел изучать JavaScript после Java и C++). Ошибки с размерностями матриц теперь ловятся компилятором еще до запуска симуляции. Да, встроенных удобных функций для работы с матрицами вроде NumPy тут нет, но простейший хелпер для матричного умножения (DotProduct) пишется за пять минут руками.
Эпизод III: Режим Бога и первые грабли
Для начала я скачал несколько бесплатных наборов ассетов. Создал «болванчика», пощупал симуляцию в режиме от третьего лица, побегал по гладкому, как стекло полю, а потом подошел к делу серьезнее.

Первым делом я убрал с карты тестовую модельку болванчика и настроил полноценную камеру для "режима Бога", то есть обычную RTS-камеру. Чтобы камера летала над полем как положено, нужно было жестко заблокировать изменения по оси Y (чтобы она не проваливалась под землю) и наклонить её к горизонту на стандартные 45 градусов.

Затем нужно было настроить кнопки управления для камеры: сначала игра отказывалась реагировать на WASD. Выяснилось, что это конфликт старой и новой систем ввода Unity. По умолчанию движок выбрал новую, а решилось всё включением поддержки обеих систем одновременно в настройках проекта. Вращение камеры по правой кнопке мыши тоже сработало не сразу: карта поворачивалась ровно на 1 кадр и застывала. Оказалось, нужно принудительно блокировать курсор (Cursor.lockState), чтобы дельта движения мыши считывалась корректно. С зумом колесиком тоже поигрался — вместо банального изменения высоты прописал смещение одновременно по осям Y и Z, теперь камера наплывает на сцену очень плавно и красиво.
Дабы упростить дебаг, я написал информационное окошко в углу экрана, открывающееся при клике на сущность. Там в реальном времени выводятся её имя, энергия, текущее поколение, текстовый статус и то, что она видит перед собой.

Эпизод IV: Бизнес-логика и «неуравновешенные» агенты
В какой-то момент появилось ощущение, что я занимаюсь только подготовкой поля и интерфейса и перешел к созданию «мозга». Первоначальная структура простейшей нейросети выглядит следующим образом: 4 входа (3 сектора зрения и уровень голода) через скрытый слой из 4 нейронов преобразуются в управляющие сигналы.

В старом прототипе у сети было всего два выхода: скорость и поворот. Для сложного взаимодействия, которое я планирую в будущем, этого мало. Как агент поймет, что ему делать при встрече с сородичем — попытаться завести дружбу или ударить? Я решил добавить третий выход, отвечающий за намерение/действие.
Этот выход нормализуется в диапазоне от -1 до 1:
· Значения от -1 до -0.3: враждебные или агрессивные действия (атака).
· Значения от 0.3 до 1: дружелюбные, мирные действия (социализация).
· Значения от -0.3 до 0.3: зона неопределенности («шум»).
Почему я не сделал жесткую двухточечную систему (-1 — враг, 1 — друг)? Допустим значение этого выхода у агента колеблется в районе нуля. Без буферной зоны получился бы классический психопат: сущность подходит к сородичу, в один кадр признается в любви, следующим кадром бьет в челюсть, а через секунду опять лезет обниматься.
Настройка зрения
В Unity через векторы прописать трехсекторный конус оказалось даже приятнее, чем вручную на Python. Для наглядности я сразу визуализировал этот конус линиями на сцене. И мой инструмент дебага окупился в первую же минуту!

Наблюдаю за агентами: шарик стоит в упор перед кустом еды, а на панели параметров входы зрения упрямо показывают «0» — пустота. Агенты абсолютно слепы, хотя стоят прямо на ресурсах.
Оказалось, я не учёл ключевую особенность движка — слои (Layers). По умолчанию все создаваемые объекты относятся к слою Default. А когда я прописывал функцию поиска объектов в конусе зрения, я просто забыл указать маску слоев, по которым нужно производить фильтрацию. Движок честно не замечал кусты. Исправил за пару минут, теперь они зрячие.
Эпизод V: Демографический взрыв (Поучительный баг)
Не обошлось и без эпичных багов игрового цикла Update, который вызывается каждый кадр. Я прописывал логику таймера эпохи: «Если таймер не кончился — работай, иначе завершай эпоху». А внутри блока «работай» стояло условие досрочного финала: «Если все агенты умерли — заканчиваем эпоху». И именно туда я умудрился влепить ветку else.
Так как в самый первый кадр игры агентов на поле явно больше нуля, условие их смерти не выполнялось. Мгновенно срабатывал мой коварный else, который принудительно завершал эпоху, очищал карту и тут же запускал спавн новой. В итоге за одну секунду симуляция прокручивала десятки эпох! На поле материализовалась безумная плотная армия из тысяч шариков, а кусты неистово мигали, меняя позиции со скоростью света.
В вебе на Реакте этот баг намертво повесил бы мне вкладку браузера, заставив кулер процессора жужжать как истребитель. Unity же выдержал удар и честно пытался отрисовать мне этот генератор хаоса. Так что стек изменен не зря.

Пока я все это писал, я постоянно прокручивал в голове модель того, что делаю, что хочу увидеть в финальном продукте, и как это все реализовать – например, ушел от структуры мозга 4-4-3 к 7-6-3. Добавил входы усталости, времени суток, опасности под ногами. Если с первыми двумя все понятно из названия, то третий создавался не просто так. Во-первых, игровое поле имеет границы, искусственно ограничивать и ставить стены или телепорт с правой стороны на левую и сверху вниз я не хотел, поэтому пришел к созданию «опасной зоны». Плюс это поможет в будущем не только для ограничения на игровом поле. Это что-то вроде нейрона безусловного рефлекса. Если в будущем я добавлю какие либо препятствия (лава, шипы, ямы, что угодно), это поможет сходить с них при наступании (по-хорошему на вход зрения их тоже надо бы добавлять, но всему свое время). На этом я пока решил остановиться. У меня еще есть идеи как можно расширить сеть и что добавить на входы, но я проект все расширяю, а запустить так и не удосужился. Поэтому решил немного ограничить свой творческий порыв, доделать проект до приемлемого состояния в текущем виде, и обучить агентов эффективно жить в проклятом мире, который сам создал.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.