Немного про нюансы оптимизации движка под геймплей с большим количеством сущностей.
Занимаясь рефакторингом текущего проекта 3D рогалика, начал переписывать реализацию монстров с Area на Kinematic Body. В чём разница? Area - это "призрачная" область, удобная для отслеживания столкновений, но не имеющая "плотности", поэтому такие враги иногда просачиваются сквозь коллайдеры, когда их рейкаст ошибочно показывает, что впереди препятствия нет и можно двигаться даже "в стену". Body, в свою очередь, - имеет "плотность" и некоторые другие плюсы (например, проще "подключить" ему гравитацию, если игра это использует).
В принципе, тут могло быть решение через Area модифицированную дополнительными рейкастами - например сделать вместо одного "прощупывающего" рейкаста, три, как в некоторых иных своих проектах. То есть переход на Kinematic Body был не единственным возможным решением.
Внутри данного прототипа разные монстрики имели уникальные скрипты, но при переписывании захотелось частично их унифицировать/оптимизировать таким образом чтобы уникальность присутствовала, но основные методы были универсальными и вызывались где-нибудь совсем отдельно, централизованно, в синглтоне.
Для банального удобства под монстров и их общие методы просто завёл отдельный глобальный синглтон E, чтобы не перегружать лишним кодом основной синглтон. И подумал, что тут напрашивается такая оптимизация - крутить цикл лишь внутри этого синглтона, а монстрикам оттуда лишь рассылать вызов функции передвижения, вместе с delta. То есть убрать циклы с самих монстров вообще.
Да, можно усмотреть в этом некий ECS-подход (Entity Component System) и подумать, что для Godot это не совсем типично. Но на самом деле, "под капотом" у движка как раз и происходит нечто близкое к ESC - всем управляют отдельные серверы (для рендера, для физики, звука). И это началось даже не с Godot 4 (хотя, в нём стало более оптимизированным, расписанным на многопоточность).
Реализация монстриков уже была довольно производительной, за счёт того что их "будит" и "усыпляет" специальная дальнодействующая Area, висящая на игроке, что отрезает им выполнение некоторого когда, пока они "спят".
Video:
Оставалось подобрать способ, которым "доносить" тики центрального цикла до монстров. Если бы никаких инструментов для подобного не было придумано, пришлось бы писать, например, занесение монстров в какой-то общий список, по которому делать вызовы. И оно того не стоило бы.
Однако, в 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: