Немного про нюансы оптимизации движка под геймплей с большим количеством сущностей.

Занимаясь рефакторингом текущего проекта 3D рогалика, начал переписывать реализацию монстров с Area на Kinematic Body. В чём разница? Area - это "призрачная" область, удобная для отслеживания столкновений, но не имеющая "плотности", поэтому такие враги иногда просачиваются сквозь коллайдеры, когда их рейкаст ошибочно показывает, что впереди препятствия нет и можно двигаться даже "в стену". Body, в свою очередь, - имеет "плотность" и некоторые другие плюсы (например, проще "подключить" ему гравитацию, если игра это использует).

В принципе, тут могло быть решение через Area модифицированную дополнительными рейкастами - например сделать вместо одного "прощупывающего" рейкаста, три, как в некоторых иных своих проектах. То есть переход на Kinematic Body был не единственным возможным решением.

Внутри данного прототипа разные монстрики имели уникальные скрипты, но при переписывании захотелось частично их унифицировать/оптимизировать таким образом чтобы уникальность присутствовала, но основные методы были универсальными и вызывались где-нибудь совсем отдельно, централизованно, в синглтоне.

Для банального удобства под монстров и их общие методы просто завёл отдельный глобальный синглтон E, чтобы не перегружать лишним кодом основной синглтон. И подумал, что тут напрашивается такая оптимизация - крутить цикл лишь внутри этого синглтона, а монстрикам оттуда лишь рассылать вызов функции передвижения, вместе с delta. То есть убрать циклы с самих монстров вообще.

Да, можно усмотреть в этом некий ECS-подход (Entity Component System) и подумать, что для Godot это не совсем типично. Но на самом деле, "под капотом" у движка как раз и происходит нечто близкое к ESC - всем управляют отдельные серверы (для рендера, для физики, звука). И это началось даже не с Godot 4 (хотя, в нём стало более оптимизированным, расписанным на многопоточность).

Реализация монстриков уже была довольно производительной, за счёт того что их "будит" и "усыпляет" специальная дальнодействующая Area, висящая на игроке, что отрезает им выполнение некоторого когда, пока они "спят".

Video:

Персонаж Godot на специальном уровне для стресс-тестов и переписанные на новую схему монстры (в данном случае их около 80 штук на уровне)

Оставалось подобрать способ, которым "доносить" тики центрального цикла до монстров. Если бы никаких инструментов для подобного не было придумано, пришлось бы писать, например, занесение монстров в какой-то общий список, по которому делать вызовы. И оно того не стоило бы.

Однако, в Godot есть группы и можно было бы остановиться на них. Но я реализовал через сигналы. То есть, синглтон, как радиостанция, посылает в цикле свой глобальный сигнал, ничего не зная о том, кому он это посылает. А монстрики уже сами подписываются на него при пробуждении. Или отписываются, когда персонаж отошёл слишком далеко.

Так как в игре не настолько много монстриков на уровне в целом (так как происходящее ближе к небольшим подземельям Diablo, где врагов много, но всё-таки не бесконечные орды на весь экран, как в Vampire Survivors), то для того чтобы профит стал более наглядным - я добавил 1080 врагов на уровень:

Для врагов со своими собственными циклами вышло примерно около 8-12 fps, против 10-20 для врагов получающих глобальный радиосигнал двигаться.

Однако, при отсутствии в кадре врагов вообще, fps не поднимался выше 20-30. Выясняя, где тут узкое место - увидел, что дело в скелетных анимациях. Дело в том что в Godot анимация сама собой автоматически не "выключается" из обработки при скрытии объекта, как и коллизии (что в старом, что в новом).

"Чинится" это следующим, немного неочевидным, образом - в настройках playback options у Аниматора нужно переводить его из режима Idle в Manual, когда анимацию требуется "заморозить". В Godot 4+ эти режимы тоже есть, но там у Аниматора вроде есть и другая опция "выключения" анимации из обработки.

Video:

1000 врагов на уровне, уже не просаживают производительность так сильно

Собственно, это не какая-то проблема движка, да и далеко не во всех проектах фигурирует столько анимированных объектов, чтобы это стало прям заметной проблемой, но имеется вот такой нюанс.

В принципе, анимированную модельку монстра ещё можно было отсоединять от родителя и присоединять обратно, что тоже эффективно остановило бы обработку его анимаций, но такой способ требует следить, чтобы к объекту не обращались(например, сменить анимацию), когда он отсоединён, да и если область "пробуждения" врагов не огромная - они так будут исчезать близко к углам экрана. И предыдущий подход отработает более плавно - у такого врага просто "замёрзнет" анимация, но он не исчезнет из видимости мгновенно.

Основные пожиратели производительности - это всё-таки тени от динамических источников света, материалы/шейдеры и прочие штуки, но в целом, оптимизация менее бросающихся в глаза моментов тоже полезна. Опять же, я и выстрелы после этого "пересадил" на движение через глобальный сигнал, что даст больший профит когда "швыряться фаерболлами" будет не только персонаж, а и враги. То есть, можно при желании спокойно делать bullet hell :)

Video:

Новый персонаж - лучница - в процессе добавления в игру

#gamedev #godot #рогалик #прототип #devlog #оптимизация