Чем плохи GameObjects в Unity

Платформа кода Unity была разработана на основе Data-Driven GameObject System от Скотта Биласа из Gas Powered Games. О ней он рассказывал на GDC 2002 на примере игры Dungeon Siege.

Структура сущностей (GameObjects), основанная на компонентах, дает много преимуществ: подталкивает пользователей отдавать предпочтение композиции, а не наследованию, сохраняет классы небольшими, чистыми и сосредоточенными на одной ответственности, а также способствует модульности. Но это в идеальном мире.

На деле реализация GameObjects в Unity оказалась далека от представленной концепции не в лучшую сторону. В данной статье не будем касаться проблемы следования принципам, а обратим внимание на вопросы производительности.

Чем плохи GameObjects в Unity

Всем привет, меня зовут Валерий, я инди разработчик в студии RaveBox. В данный момент, я работаю над игрой "Taxi Pro".

Введение

При работе с обычными GameObjects основным преимуществом для девелопера является удобство в процессе разработки. Объекты существуют одновременно как в сцене, так и в режиме выполнения (runtime), что упрощает наблюдение за, например, неигровыми персонажами в окне игры и позволяет производить манипуляции с ними в окне сцены. Однако это имеет как свои плюсы, так и минусы. Один из недостатков заключается в том, что для билда игры не требуется authoring-логика (логика, используемая внутри редактора и присутствует в GameObjects), и она лишь добавляет лишнюю нагрузку. Еще одним недостатком является плохая оптимизация, как по использованию процессора, так и оперативной памяти. При изучении статей в Интернете о том, как улучшить производительность в Unity-проектах, большая часть советов касается сокращения аллокации памяти и минимизации сборки мусора (Garbage Collection, GC). Давайте рассмотрим основные проблемы, связанные с обычным подходом к разработке в Unity.

Garbage Collection

Garbage Collection - это удобный способ контролировать освобождение памяти. Однако стоит помнить, что этот процесс не может выполняться параллельно с выполнением игрового кода. Действительно, для освобождения памяти необходимо временно приостановить выделение новой, что в свою очередь требует приостановки игрового процесса, даже если это происходит лишь на краткое время. Такие периодические приостановки могут негативно сказаться на графике фреймтайма и плавности игры. Представьте, что пик активности сборки мусора совпадает с другими высоконагруженными событием в приложении.

GC оказывает влияние на производительность игры и плавность геймплея
GC оказывает влияние на производительность игры и плавность геймплея

Фрагментация памяти

Для того чтобы объект был размещен в памяти, необходимо найти непрерывный участок, в который он полностью поместится. Однако при постоянном выделении и освобождении памяти начинают образовываться небольшие пустые фрагменты, в которые новые объекты могут не поместиться. Эта ситуация называется фрагментацией памяти и при продолжительной работе игры она может привести к сбоям. В таком случае объем памяти может быть достаточным, но отсутствует непрерывное свободное пространство, в которое можно было бы разместить новые объекты. Для решения этой проблемы используется дефрагментация, но без разумного управления выделением и освобождением памяти она может оказаться неэффективной. Посмотрите на рисунок ниже: участки A и C были освобождены, но новый блок размером 128 байт не удается разместить на их месте.

Новый объект нашел себе место, но в следующий раз ему может так не повезти<br />
Новый объект нашел себе место, но в следующий раз ему может так не повезти

В больших играх для консолей и ПК проводят тестирование, в рамках которого игра запускается и работает непрерывно в течение нескольких дней, и она должна успешно пройти этот тест, без единого вылета. Одной из основных причин возникновения сбоев после продолжительной работы является проблема фрагментации памяти.

Паттерн Object pool

Для решения проблем с фрагментацией памяти и постоянным выделением и очисткой было разработано множество методов, и одним из них является паттерн "Object Pool" (пул объектов). Его суть заключается в том, чтобы не создавать объекты заново, а заранее создать их при старте сцены, затем включать и выключать их по мере необходимости. Это действительно позволяет существенно снизить количество операций выделения и освобождения памяти, однако при этом добавляет дополнительную сложность в архитектуру приложения.

Снаряды часто создаются и уничтожаются (GC не останется без работы)
Снаряды часто создаются и уничтожаются (GC не останется без работы)

Кэширование

Некоторые методы при каждом своем вызове могут создавать огромное количество новых объектов в каждом кадре, не кэшируя их, например, 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() выполнены через один менеджер.

Если методы Update вызываются менеджером, а не Unity напрямую, то мы имеем пятикратный прирост производительности
Если методы Update вызываются менеджером, а не Unity напрямую, то мы имеем пятикратный прирост производительности

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 есть свои проблемы, но я расскажу о них подробнее в статье, которая выйдет на следующей неделе.

Надеюсь, вы приятно провели время, читая статью, и, возможно, узнали что-то новое. Если у вас возникли вопросы или замечания, не стесняйтесь оставить их в комментариях. Я регулярно проверяю сайт и обязательно отвечу на все ваши сообщения. До следующей недели!

Полезные ссылки:

2424
39 комментариев

Давно не использую Unity, но приписывать всё это конкретно к Unity-проблемам, и делать из GameObjects какое-то зло - неправильно. Вышеперечисленные моменты касаются многих движков - количество объектов и их структурирование важны, пул объектов помогает, хотя и не везде применим, кэширование - хорошая практика, минимизация лишних вызовов в цикле и вобще - хорошая практика, сборщик мусора имеет свои нюансы, и так далее.
В разных движках, естественно, данные проблемы решены несколько иначе, но за это уплачена разная цена - не хочешь использовать GC, тогда вот тебе какие-нибудь плюсы и иди работай с памятью сам. Это не какие-то очередные GameObjects во всём виноваты, а те разработчики, которые специфику используемого движка не учитывают.

4
Автор

Действительно, проблема GameObjects везде одинаковая, не только на Unity. Стандартный GameObject подход очень легок для освоения, поэтому он привлекает разработчиков. Но под всем этим скрывается ООП, которая является очень простой парадигмой для понимания, но очень сложной в реализации.

Один умный человек подметил, что ECS штука хорошая, но когда она работает на низком уровне движка (рендер, физика, звук, сеть, вот это вот все), и разработчик, работающий с движком без особой необходимости с ней не взаимодействует. На уровне игровой логики удобнее как раз компонентный подход, за некоторым исключением.

2
Автор

Да, вы правы, писать некоторые вещи на объектах удобнее. Например, ИИ, так как большинство общепринятых методов использует полиморфизм. Но, может, если бы Ecs был такой же популярный как и GO, мы бы сейчас говорили обратное.

Проблемы с выделением памяти и Garbage Collection

Как по мне, нет здесь никакой проблемы, вопрос того, как вы будете управлять объектами и памятью.

Если сильно захотеть, можно взять контроль в свои руки и вызывать Garbage Collector руками и тогда у вас не будет проблем с тем, что он совпадет с каким-то активным игровым моментом.

1
Автор

Моя идея была в том, что Garbage collector скрывает управление памятью от разработчика и он расслабляется. Конечно можно использовать ручные методы для вызова Garbage Collector в ненагруженные моменты в игре, но тогда это уже будет больше похоже на ручную очистку в стиле C++. Большинство разработчиков не запариваются с этим.

incremental gc не решает? Я забил на всякие NonAlloc версии методов и всё равно никаких заиканий не вижу.