Чем плохи GameObjects в Unity
Платформа кода Unity была разработана на основе Data-Driven GameObject System от Скотта Биласа из Gas Powered Games. О ней он рассказывал на GDC 2002 на примере игры Dungeon Siege.
Структура сущностей (GameObjects), основанная на компонентах, дает много преимуществ: подталкивает пользователей отдавать предпочтение композиции, а не наследованию, сохраняет классы небольшими, чистыми и сосредоточенными на одной ответственности, а также способствует модульности. Но это в идеальном мире.
На деле реализация GameObjects в Unity оказалась далека от представленной концепции не в лучшую сторону. В данной статье не будем касаться проблемы следования принципам, а обратим внимание на вопросы производительности.
Всем привет, меня зовут Валерий, я инди разработчик в студии RaveBox. В данный момент, я работаю над игрой "Taxi Pro".
Введение
При работе с обычными GameObjects основным преимуществом для девелопера является удобство в процессе разработки. Объекты существуют одновременно как в сцене, так и в режиме выполнения (runtime), что упрощает наблюдение за, например, неигровыми персонажами в окне игры и позволяет производить манипуляции с ними в окне сцены. Однако это имеет как свои плюсы, так и минусы. Один из недостатков заключается в том, что для билда игры не требуется authoring-логика (логика, используемая внутри редактора и присутствует в GameObjects), и она лишь добавляет лишнюю нагрузку. Еще одним недостатком является плохая оптимизация, как по использованию процессора, так и оперативной памяти. При изучении статей в Интернете о том, как улучшить производительность в Unity-проектах, большая часть советов касается сокращения аллокации памяти и минимизации сборки мусора (Garbage Collection, GC). Давайте рассмотрим основные проблемы, связанные с обычным подходом к разработке в Unity.
Garbage Collection
Garbage Collection - это удобный способ контролировать освобождение памяти. Однако стоит помнить, что этот процесс не может выполняться параллельно с выполнением игрового кода. Действительно, для освобождения памяти необходимо временно приостановить выделение новой, что в свою очередь требует приостановки игрового процесса, даже если это происходит лишь на краткое время. Такие ��ериодические приостановки могут негативно сказаться на графике фреймтайма и плавности игры. Представьте, что пик активности сборки мусора совпадает с другими высоконагруженными событием в приложении.
Фрагментация памяти
Для того чтобы объект был размещен в памяти, необходимо найти непрерывный участок, в который он полностью поместится. Однако при постоянном выделении и освобождении памяти начинают образовываться небольшие пустые фрагменты, в которые новые объекты могут не поместиться. Эта ситуация называется фрагментацией памяти и при продолжительной работе игры она может привести к сбоям. В таком случае объем памяти может быть достаточным, но отсутствует непрерывное свободное пространство, в которое можно было бы разместить новые объекты. Для решения этой проблемы используется дефрагментация, но без разумного управления выделением и освобождением памяти она может оказаться неэффективной. Посмотрите на рисунок ниже: участки A и C были освобождены, но новый блок размером 128 байт не удается разместить на их месте.
В больших играх для консолей и ПК проводят тестирование, в рамках которого игра запускается и работает непрерывно в течение нескольких дней, и она должна успешно пройти этот тест, без единого вылета. Одной из основных причин возникновения сбоев после продолжительной работы является проблема фрагментации памяти.
Паттерн Object pool
Для решения проблем с фрагментацией памяти и постоянным выделением и очисткой было разработано множество методов, и одним из них является паттерн "Object Pool" (пул объектов). Его суть заключается в том, чтобы не создавать объекты заново, а заранее создать их при старте сцены, затем включать и выключать их по мере необходимости. Это действительно позволяет существенно снизить количество операций выделения и освобождения памяти, однако при этом добавляет дополнительную сложность в архитектуру приложения.
Кэширование
Некоторые методы при каждом своем вызове могут создавать огромное количество новых объектов в каждом кадре, не кэшируя их, например, Physics.OverlapSphere(), который возвращает массив Collider[]. При каждом вызове этого метода мы выделяем память для массива коллайдеров, используем его и тут же отбрасываем результат, а в следующем кадре все повторяется. Для предотвращения этой проблемы нужно использовать специальные методы. Например, методы в классе Physics с суффиксом NonAlloc, которые используют изначально выделенный массив при каждом вызове. Второй вариант - это разрабатывать собственные механизмы кэширования информации. Важно помнить, что при этом всегда необходимо учесть, сколько памяти потребуется в будущем. Если мы выделим слишком много, это будет пустой расход ресурсов, а если слишком мало, то возможно потребуется разработать логику для выделения нового участка памяти в таких случаях.
Подводя итог главе, посвященной управлению памятью, отметим, что GameObjects, используемые в разработке игр, могут стать серьезной проблемой. Под них постоянно выделяется память в куче, и это происходит незаметно для разработчика, поскольку ответственность за ее очистку лежит на Garbage Collector. На самом деле, управление памятью требует внимания, так как недостаточный контроль в этом вопросе может привести к серьезным последствиям. Ложная простота языка C# с автоматической сборкой мусора несет опасность для небрежных разработчиков.
Кроме проблем с памятью, у GameObjects также есть некоторые проблемы, связанные с использованием процессора. Это будет рассмотрено далее.
Проблемы с производительностью CPU
GameObjects сами по себе оказывают влияние на производительность игры. При небольшом их количестве изменения в производительности могут показаться незаметными, но если добавить на сцену, скажем, несколько десятков тысяч простейших NPC с даже самой простой графикой, то частота кадров в секунду (FPS) резко упадет практически до нулевого значения. GameObjects, по сути, представляют собой контейнеры для компонентов, но количество самих объектов имеет непосредственное влияние на производительность.
Более того, на компонентах GameObjects часто присутствует специальный Unity Callback под названием Update(). Даже пустой вызов этого метода вносит свой вклад в снижение производительности, даже если он не выполняет никаких действий. Этой теме посвящена статья '10000 Update() calls'. На графике ниже представлен результат вызова 10 000 методов Update() непосредственно Unity, а во второй строке все десять тысяч вызовов Update() выполнены через один менеджер.
Cache Miss
Более того, объекты, включая GameObjects, часто не оптимизированы для кэша процессора, и они не являются "cache-friendly". Когда процессор загружает данные из памяти, он не берет только те, которые ему нужны, а захватывает сразу целый участок последовательной памяти. Это сделано потому, что процессор предполагает, что в программе данные будут использоваться последовательно, как, например, данные в массиве. Таким образом, существует большая вероятность, что программа обратится к области памяти, которая уже загружена в кэш (cache hit). Однако, если придется обращаться к данным, которых нет в кэше (cache miss), это приведет к обращению к оперативной памяти, что является значительно более медленной операцией — от 10 до 100 раз медленнее.
Согласитесь, что замедление работы в 100 раз нежелательно для игры. Проблемы с кэш-промахами возникают, когда мы обращаемся к объектам, в частности, к GameObjects. Эти объекты содержат ссылки на компоненты, которые, в свою очередь, могут ссылаться на другие компоненты и объекты. Каждое обращение по ссылке для процессора сопровождается постоянной необходимостью переноса данных из оперативной памяти в кэш. Это происходит по причине крайне малой вероятности события, при котором исходный объект и объект, на который он ссылается, находятся в кэше одновременно.
Итоги
Итак, пройдемся по плюсам и минусам использования GameObjects еще раз.
Плюсы:
- Удобство написания кода
- Многие разработчики знакомы с ООП и GameObjects им понятны
Минусы:
- Проблемы с выделением памяти и Garbage Collection
- Проблемы с производительностью CPU
- Не "cache-friendly"
- Усложнение кода, для нивелирования этих проблем
- ООП подход при кажущейся простоте совсем не прост (Требует соблюдения принципов SOLID, знание паттернов и построения архитектуры приложения)
Заключение
Я хотел бы обратить ваше внимание на проблемы, связанные с использованием GameObjects. Хочу подчеркнуть, что это не единственный и, скорее всего, не самый оптимальный способ разработки игр. Недавно в Unity был выпущен пакет Unity.Entities 1.0, который входит в состав DOTS (Data-Oriented Technology Stack). В его основе лежит паттерн ECS (Entity-Component-System), который лучше подходит для архитектуры игр. Этот подход значительно удобнее для разработки и обеспечивает значительное увеличение производительности в режиме выполнения. Не буду скрывать, что у реализации ECS в Unity есть свои проблемы, но я расскажу о них подробнее в статье, которая выйдет на следующей неделе.
Надеюсь, вы приятно провели время, читая статью, и, возможно, узнали что-то новое. Если у вас возникли вопросы или замечания, не стесняйтесь оставить их в комментариях. Я регулярно проверяю сайт и обязательно отвечу на все ваши сообщения. До следующей недели!
Полезные ссылки: