Введение

В основном эта статья будет полезна начинающим разработчикам, у которых уже есть небольшой опыт работы с движком (может пару месяцев или пол года). Если вы считаете себя мидл девелопером - то думаю, что вы тоже найдете для себя что-то полезное и нужное. Уверен, что и артистам, работающим с Unreal Engine, данная статья может быть полезна.

Стоит заметить, что если вы уже опытный синьор помидор - и этап первоначального накопления опыта вы давно прошли - для вас тут будет мало чего применимо на практике, а некоторые советы - могут быть вредны, например в части “Основа в С++”.

Эта статья - не туториал или гайд “как делать правильно”, я не даю каких то четких последовательностей действий, что бы добиться наилучшего результата. Я стараюсь поверхностно пройтись по, так как мне кажется, наиболее важным для новичков аспектам разработки на Unreal Engine. Моя цель - подсказать вам направления, в которых можно и нужно копнуть поглубже. Рассказать что есть и для чего это можно использовать. Почти для всех пунктов я привожу ссылки на более конкретные гайды и описательные статьи по темам. Они могут вам помочь.

Статья получилась весьма большая. Сначала это был маленький список тем, буквально 2-3 страницы, которые я активно пропагандировал несколько лет назад в компании, в которой я работаю. Тогда и сейчас я много времени уделял наставничеству начинающих разработчиков и вместе с этим сам многому научился. Недавно я случайно откопал эту заметку, решил раскачать ее в полноценную статью и поделиться ею с сообществом! Надеюсь что описанные мною вещи будут вам интересны и полезны. Желаю приятного чтения :-)

Общие архитектурные советы

Основа в С++

???? Скорее всего со временем вы почти забудете что Blueprint существует.

Если вы делаете игру на Unreal Engine которую планируете поддерживать после релиза или в целом планируете долгую разработку (пол года и более), скорее всего вам потребуется писать на С++, как бы вам этого не хотелось.

Если вы еще маленькая/молодая/не опытная команда скорее всего вы подумаете, о том, что блюпринты вас спасут. Да, это действительно мощный инструмент, на котором можно делать много чего, но заменить С++ в полной мере он вам не позволит. В какой то момент вы просто станете смотреть на лапшу прилипшую ко дну кастрюли и плакать.

Чтобы не закладывать себе бомбу замедленного действия и не тратить потому кучу времени на рефакторинг, не хвататься за голову, когда какой-то маленький кусочек функционала придется реализовать в С++ (например в целях оптимизации), следует придерживаться нескольких простых правил:

  • У любого относительно большого/важного объекта должен быть С++ родитель

  • Все поля за исключением временных должны быть объявлены в С++ и вынесены в блюпринты

  • Большее число функций ваших объектов должны быть implementable или native событиями С++. Реализацию же, вы можете делать на блюпринтах

  • Типы, такие как перечисления или структуры должны быть объявлены в С++ и вынесены в блюпринт как BlueprintType

Это весьма просто и под силу даже самому зеленому джуну, главное верить в себя! В таком случае у вас не будет барьера что бы писать С++ тогда, когда это потребуется. Вам не нужно будет переносить основу ваших объектов и заниматься этим несколько часов (а может и дней) к ряду, вам просто нужно будет переносить отдельные куски логики, что займет совсем немного времени и моральных ресурсов.

Когда в вашем проекте будут появляться длинные макароны, более 20 блоков можно задуматься о переносе их в С++, ведь теперь это не так сложно: вы знаете как оно работает, вы это уже отладили и у вас получится переписать это так, чтобы оно работало почти сразу. Особенно стоит обратить внимание на ту логику, которую вы в будущем планируете расширять, было бы здраво переписать то, что есть сейчас на С++ с учетом точек расширения, которые вы можете вынести в блюпринты, следуя советам указанным выше. Таким образом, у вас уже будет место для будущих улучшений.

Но есть и вещи которые делать в макаронах можно и в какой то степени нужно, в целях ускорения разработки:

  • Работу с эффектами/материалами, анимациями, одним словом - визуал и звуки

  • Таймлайны, по сути тоже, часто, можно отнести к визуалу

  • UI, но так же стоит учитывать о наличие C++ родителя у виджетов

  • Последовательности событий с делеями, аля заскриптованные моменты

Важный момент про UI! Дизайнить UMG виджеты в С++ вы не сможете, а делать игровой UI на Slate та еще плохая затея. Но не нужно унывать, есть замечательный мета-модификатор BindWidget. Если вы объявите переменную компонента (сабвиджета) вашего виджета с этим модификатором и в БП наследнике создадите элемент с таким же именем - то он будет положен в эту переменную и вы сможете легко и просто использовать ее в С++, без какого либо дополнительного кода по получению компонентов и т.п. Тут можно почитать про BindWidget.

Следуйте хоть каким то паттернам

Паттернов очень много, они предназначены для решения шаблонных задач, которых в программировании, на самом деле очень много, хоть мы все и говорим, что программирование - это творческое занятие! Зная о паттернах и следуя им вам будет проще понимать код, который написал с использованием паттернов, и ваш код будет более логичным, понятным и объяснимым. Вам не придется понимать, почему вы сделали именно так, а не иначе (на самом деле конечно придется, но в меньшей степени).

Так же следование паттернам может сберечь вас от принятия плохих архитектурных решений, созданий некоторых велосипедов и выстрелов себе в ногу.

Про то какие паттерны существуют и как их использовать можно почитать тут.

Используйте и изучайте готовые решения

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

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

Хочу заметить, что с найденной информацией тоже нужно правильно обращаться. Возможно что-то вы сможете просто использовать, как есть. Что то вам понадобиться модифицировать под себя. А чем то просто вдохновиться и сделать свое решение.

Соблюдайте цикл жизни ваших сущностей

Цикл жизни каждого объекта должен начинаться с корректной инициализации, а заканчиваться остановкой его работы и удалением. Такой подход упростит вам написание кода и поможет избежать того, что мы называем undefined behaviour (это когда хрень какая то происходит).

Инициализация

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

Если объект использует какие то вещи, которые объекту нужно создать или получить из внешней среды - это нужно учесть во время инициализации. Чтобы в коде основной логики объекта не мешался поиск какого то актера на уровне, а просто использовались уже проинициализированные поля. Архитектура Unreal уже имеет функции у многих базовых классов, которые используются ими для инициализации и их можно переопределить:

  • все UObject - PostInitProperties, PostLoad

  • AActor - PostInitializeComponents, OnConstruction, (и другие)

  • UActorComponent - OnRegister, InitializeComponent

  • UUserWidget - OnNativeInitialized, OnNativeConstruction

  • Старайтесь не использовать BeginPlay для инициализации - вместо него есть много всего. Особенно в мультиплеерных играх, если используешь BeginPlay- всегда нужно делать в голове, что скорее всего он вызовется только на сервере.

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

Правильное удаление

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

Важно не только удалиться но и сделать это правильно: скинуть таймеры, разбиндить делегаты, удалить какие нибудь зависимые объекты, оповестить кого надо. Unreal Engine многое из этого сделает за вас благодаря GC и рефлексии, но возможно что то у него не получится, по этому не стоит пренебрегать четким заверением работы объекта.

Функции подходящие для завершения работы:

  • все UObject - BeginDestroy, FinishDestroy

  • AActor - EndPlay, Destroyed

  • UActorComponent - OnUnregister, OnComponentDestroyed

  • UUserWidget - NativeDestruct

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

Используйте базовые классы из GameFramework (и не только) правильно

Делай так, как предлагает анриал, наследуйтесь от одного из этих классов для выполнения ваших задач и вам будет классно:

  • GameMode - подключение игроков, стадии игры и условия победы

  • GameState - статистика/управление состоянием игры, возможно управление событиями происходящими в игре

  • PlayerState - состояние игрока

  • Pawn - сущность в игровом мире, которой может управлять игрок или ИИ. Подходит для персонажей игрока

  • Character - Pawn гуманоидного вида. Если не вдаваться в детали отличается от Pawn только тем, что отлично подходит для реализации гуманоидных персонажей управляемых игроком или ИИ.

  • PlayerController - неизменная сущность игрока, одной из первых получает ввод, характеризует конкретный клиент.

  • MovementComponent - логика перемещения, если не подходит существующие компоненты движения, написать свой на базе этого класса будет лучшим решением

  • ActorComponent - если вы хотите дать своим актерам какое то новое свойство или функционал, хорошим вариантом может стать инкапсуляция этой логики в компоненте.

  • <Any>Subsystem - ваша кастомная логика, у которой может быть свое состояние, отсутствует потребность в репликации и которая не является объектом в игровом мире.

  • AnimInstance - управление анимациями, если ваша работа с анимациями комплекснее чем написание машины состояний и простенький анимационный блюпринт, например ,предполагает какие то сложные вычисления - вы можете воспользоваться классом анимационного инстанса. Это родитель всех анимационных принтов.

Используйте сабсистемы

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

Какие у этого решения плюсы:

  • Сабсистемы создаются и удаляются сами, не нужно над этим заморачиваться

  • Инициализация вместе с тем, к чему относится (Editor, Engine, World, LocalUser, GameInstance)

  • Весьма удобная доступность из любой части кода игры (в том числе из блюпринтов)

  • Возможность инкапсулировать в вашей сабсистеме какую то часть логики, а не писать ее в класс где уже есть другая логика

https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/Subsystems/ - тут можно почитать про них страницу в документации. В документации нету UWorldSubsystem но она существует и в ней можно делать вещи, которые относятся к миру игры.

Не парсите JSON’ы руками

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

JSON желательно собирать и парсить при помощи структур и конвертации (FJsonObjectConverter) json в структуру и обратно. По структуре очень просто можно понять что хранится в json, не прибегая к прочтению кода парсинга. А так же это очень быстро, просто и эффективно.

Парсить жсоны руками - плохо, почему:

  • Нужно писать какой то свой код для парса (время, возможно будут ошибки)

  • Не гибко, нужно масштабировать так же руками с обоих сторон

Если что-то плохо конвертируется в Json, например если в структуре есть TOptional или иные поля не поддерживающие рефлексию можно использовать ImportTextItem или ExportTextItem, это грубо говоря парсинг, написанный руками, но в некоторые моменты приходится писать свою логику для таких базовых вещей как парс JSON’ов.

Взаимодействие

Под взаимодействием объектов в рамках игры мы обычно понимаем вызов функций из одного объекта в другом. Или вызовы из одной группы объектов в другую (например разные игровые системы).

Для контроля взаимодействия наших объектов и систем есть два хороших решения:

Используйте события и делегаты

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

???? Возможно, в будущем я напишу статью о том, как можно весьма просто и удобно пользоваться делегатами и событиями в Unreal и брать от них все.

На мой взгляд самый важный принцип парадигмы ООП, который по сути не реализован в С++ и многих Си-подобных языках - это обмен сообщениями. Такой подход наиболее хорошо раскрыт в языке Smalltalk, однако этот язык не стал популярным.

При помощи событий вы сможете построить (весьма безопасную) систему обмена сообщениями, в которой объект, который хочет послать сообщение, будет вызывать событие, а объекты подписанные на событие - будут являться принимающей стороной. Такая система удобна тем, что позволит быстро расширять ваши взаимодействия, и достаточно просто отслеживать неполадки в взаимодействие ваших сущностей.

Используйте интерфейсы (программные)

Программные интерфейсы очень полезны для инкапсуляции функционала, которая в свою очередь способствует модульности вашего решения и позволяет проще вносить изменения в конкретный модуль (или его часть), сокрытый под интерфейсом.

Проектировать интерфейс лучше исходя из потребностей модуля который к нему будет обращаться, а не исходя из возможностей модуля который его реализует. Такой подход позволяет уменьшит blast radius.

Blast radius - это понятие, описывающие ущерб в виде багов или неработоспособности системы вызванный внедрением нового функционала или переработкой старого.

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

Один из принципов SOLID, а именно принцип разделения интерфейсов (I) гласит:

Создавайте узкоспециализированные интерфейсы, предназначенные для конкретного клиента. Клиенты не должны зависеть от интерфейсов, которые они не используют.”

Под клиентом тут понимается сущность, которая использует интерфейс, а не реализует его.

Подробнее про принципы SOLID можно почитать в этой статье. Или в цикле видео на канале Сергея Немчинского.

Оптимизации

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

Однако, перед тем как переходить коду или ассетам есть несколько простых вариантов, которые вам могут помочь если дело не так плохо - настройки проекта. Вряд ли конечно оно решит вашу проблему, но посетить их стоит.

В настройках проекта (Вкладка Project Settings) есть много всего, что может влиять на производительность. В основном оно живет в группе Engine или в плагинах, если вы их используйте. Посмотрите на ваши настройки, погуглите что они значат, вдруг после изменения какой то из них ситуация станет лучше. Можно копнуть глубже и погрузиться в конфиги, так тоже много чего интересного и полезного, не только для оптимизаций.

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

Как узнать что лагает

Самое важное на мой взгляд в процессе оптимизации - понять что вызывает проблемы. И начинать чинить с самых тяжелых мест. Для поиска проблем у вас, на самом деле очень, много вариантов!

Есть инструменты многие прям внутри движка, позволяющие оценивать производительность в рантайме, есть сторонний софт:

Тик

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

Если говорить о тике - самые важные показатели: среднее и пиковое время обработки кадра. Среднее - это общая производительность, тот самый FPS, а пиковая - это фризы/лаги.

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

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

Самые распространенные оптимизации тика, которые могут вам помочь:

  • Тикать реже (не каждый кадр) если это возможно, а в какие то моменты можно не тикать вовсе

  • Если вы в тике проверяете произошло ли какое то событие - переведите это на делегат

  • Обычно долго отрабатывают функции типа GetAllActorsOfClass (которые подразумевают обработку большого массива) возможно у вас получится искать всех нужных актеров в момент инициализации и использовать уже найденных

  • Перепишите тик на С++, если на блюпринтах он выглядит красиво и не лагучо, но все равно долгий

  • Возможно некоторые вещи у вас получится сделать асинхронно, например обновлять множество объектов на за один кадр а по кускам за несколько кадров.

  • Можно зайти дальше и вытащить что-то в другой поток. В С++ например вы можете очень просто запускать асинхронный код в другом потоке при помощи функции AsyncTask. Если вам нужно обработать большой массив данных вы можете прибегнуть к использованию ParallelFor это функция доступная в С++, которая обработает ваш массив в разных потоках и передаст управление вызывающему, когда закончит, для больших массивов это быстрее, чем делать в одном потоке. Тут вы можете найти примеры использования AsyncTask и ParallelFor

  • Может быть что-то может не тикать в принципе, проверьте свои тики на бесполезный код, возможно вы что-то когда то давно забыли убрать

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

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

https://www.youtube.com/watch?v=CBP5bpwkO54

Зависимости и загрузка

Помимо тика еще одно узкое место - загрузка контента. Что бы избегать лагов на загрузке чего либо следует правильно строить зависимости ваших ассетов друг от друга. Связанность ассетов влияет на то что и когда загружается. Связи могут быть как жесткие, так и мягкие.

Жесткие связи могут вызывать две проблемы: лаги и долгие загрузки.

Все поля для ассетов, которые вы указываете как UMyAssetType* FieldName; являются жесткими. И при загрузке сущности хранящей в этом поле ссылку на тот или иной ассет произойдет так же загрузка указанного ассета, даже если он в данный момент не нужен.

Может доходить до того, что при открытии любого уровня движок грузит в ОЗУ всю игру, из-за чего, казалось бы, маленький уровень загружается 10-ки минут. А лаги у вас могут быть когда вы пытаетесь что либо заспавнить, например оружие которое может наносить урон противникам, в таком случае оно потянет за собой загрузку противников и все их зависимости. Все это будет происходить в рамках одного кадра, что сильно растянет его время и вызывает лаг/фриз.

???? Давным давно я сам так делал и люто от этого страдал.

У этих 2-х проблем есть ультимативное решение! Используйте мягкие ссылки везде где это можно!

Для определения мягкой (не слабой) ссылки в UE есть специальные типы:

  • TSoftObjectPtr<T> - для определения типизированной ссылки на объект

  • TSoftClassPtr<T>- для определения типизированной ссылки на класс, аналог TSubclassOf<T>

  • FSoftObjectPath и FSoftObjectClass - нетипизированные мягкие ссылки для объектов и классов соответственно, однока мы можете их типизировать при помощи мета модификаторов:

    • AllowedClasses для объектов

    • MetaClass и MustImplement для классов (второй существует но по какой то причине не указан в документации)

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

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

Синхронная загрузка представляет из себя вызов одной функции:

  • TryLoad для мягкий путей (SoftPath)

  • LoadSynchronous для мягких указателей (SoftPtr)

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

Сборщик мусора

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

Можно включать кластеры GC, что бы GC проводил поиск мусора быстрее.

Если мусора много - можно вызывать GC чаще, в таком случае он будет удалять меньше объектов.

Про сборку мусора я писал отдельную статью, советую ее почитать, говорят хорошая.

Скелетные меши

По сути оптимизация скелетный мешей - это тоже оптимизация тика, но весьма конкретного и тяжелого.

Вы можете использовать настройки на вкладке Details/Optimization, там их много, посмотрите на них, они реально работают. Например там можно не тикать когда игрок не видит или не проверять оверлапы при воспроизведении анимаций.

Еще вы можете следить за тем, что бы одинаковые склетал меши тикали вместе, так как их тики очень большие и глубокие. (Возвращаю вас к видосику про оптимизации Sea of thieves)

А еще вы можете их инстансить (ну почти)! Это прикольно, попробуйте.

Лоды

Во первых, если у вас есть меши - у них должны быть лоды. При чем нулевой лод должен быть видет в те моменты, когда вам действительно нужна самая лучшая детализация. Зачастую даже на переднем плане хорошо себя показывает и первый лод. Лодировать можно как статические сетки так и скелетные.

Проблема которую решают лоды не тяжелая работа вершинного шейдера, а перерисовка, то есть обновление одних и тех же пикселей при отрисовки полигона. Эту проблему решают и другие механизмы, но лоды играют немаловажную роль в этом.

Качественные материалы с полным сетом PBR текстур - весьма дорогие. На 0-1-2 лодах следует так же менять материалы на более легкие.
С большой долей вероятности на далеких объектах можно порезать использование каких либо текстур (например шершавости), и заменить на скалярный параметр. На супер далеких лодах можно просто использовать солидные цвета если это не заметно.

Важно! Если вы используете Nanite мои советы для вас могут быть не сильно релевантны.

Батчинг

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

В анриале есть 2 инструмента которые могут в это: Actor Merging и HLOD.

Actor Merging - это инструмент движка, который позволяет вам вручную запекать ваши меши и актеры в один меш, материалы и текстуры он так же может запекать.

HLOD. Если прошлый инструмент подразумевает ручную работу, то HLOD уже работает почти автоматически в процессе игры вам его нужно лишь настроить. Он использует смерженные прокси мешы в процессе игры и оставляет вам возможность редактировать ваши меши по одиночке, в процессе левел дизайна. Однако эта штука очень любит кушать оперативу, стоит быть аккуратным.

Стриминг уровней

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

Следовательно у вас есть 2 пути оптимизации:

  1. Делать уровни поменьше, что бы они загружались побыстрее. Если уровень загружается дольше 5-ти секунд - это можно считать большим уровнем

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

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

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

Важное замечание! В эдиторе стриминг уровней всегда блокирующий - то есть он загружает весь уровень за один кадр. Если вы будете проверять оптимизацию стриминга в эдиторе - скорее всего он у вас будет лагать всегда.

И еще одно! Это относится только в UE4, так как в UE5 это уже по другому работает.

Материалы и текстуры

Оптимизация текстур, а особенно материалов - очень глубокая тема. Мы рассмотрим несколько ее аспектов:

  1. Старайтесь использовать текстуры реально нужного вам разрешения, не нужно везде использовать 4к или 2к текстуры. Порой можно обойтись даже 256х256 текстурой, просто правильно ее использовать в материале.

  2. Ваши текстуры должны быть со стороной в степени 2-ки. Желательно с соотношением сторон 1:1 или 1:2.
    Это важно в первую очередь из-за совместимости. Квадратами в степени 2-х раньше выделяли память видеокарты (будь то десктопная или мобильная) и проводили все операции зная что текстура именно такая.
    То есть если у вас текстура например 1000х100 то в памяти она будет занимать столько же сколько и 1024х1024. Сейчас многие десктопные видеокарты, начиная с 10-х годов нормально работают с текстурами не в степени 2-х. Но такая проблема все равно сохраняется на некоторых платформах нашего времени, преимущественно на мобильных.
    Например, IOS не квадратные текстуры не в степени 2-х вообще не будет рендерить, вы получите просто белые пятна, вместо вашей текстуры.

  3. Старайтесь использовать материал инстансы вместо материалов. По большей части проблема в удобстве. Я отношусь к материалам следующим образом: материал - это шейдер, а материал инстанс - это материал. Тут вот есть про это ссылка. (раньше тут было другое, но мне объяснили где я не прав и я переобулся)

  4. Следите за сложностью ваших шейдеров, чем он сложнее - тем дольше рисуется. Это можно делать при помощи режима просмотра Shader Complexity

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

Заключение

Как вы могли заметить я очень мало (почти ничего) сказал про сеть в Unreal Engine, это очень большая тема и я не придумал как можно раскрыть ее коротко, ведь статья правда получилась очень большой и без сети. Когда нибудь, я думаю что и про сеть статью напишу, а пока что могу предложить вам ознакомиться с штукой, которая называется Unreal Network Compendium, это достаточно старая, но до сих пор актуальная статья, которая, как мне кажется, полноценно описывает сеть в Unreal Engine.

Буду рад вашим отзывам и комментариям, если у вас есть какие то вопросы касательно тем, описанных в статье или вы с чем то не согласны - давайте обсудим!

Комментарии (21)


  1. G1ROG
    03.05.2022 01:08
    +2

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


    1. tzay Автор
      03.05.2022 01:16
      +1

      Большое спасибо за комментарий, рад что вам понравилось. В целом то, что вы сказали и побудило меня начать писать эти статьи. Когда я начинал свое становление, было весьма сложно искать информацию в принципе, не важно каким языком обладаешь. И поиск информации сжирал очень много времени и моральных ресурсов.
      Сейчас на многие фундаментальные вопросы до сих пор нет четких ответов и я стараюсь это исправить. О сети очень советую network compendium, он правда не потерял актуальность и несет в себе все нужные знания, что бы делать сетевые игры. Но возможно и сам что нибудь напишу про сеть. :-)


  1. SmallSnowball
    03.05.2022 01:09

    > То есть если у вас текстура например 1000х100 то в памяти она будет занимать столько же сколько и 1024х1024.
    Не буду говорить за мобильные платформы, т.к. не знаю как там дела обстоят, но на десктопах это не так. Память под текстуры выделяется не квадратами (ну там есть заморочки со свиззлом и с блочной компрессией, но обычно эти блоки небольшие, не больше 4х4). Проверить можно легко, завести такую текстуру и поглядеть, что скажет мемрепорт.

    > Если материал используется в двух местах - будет его две копии в памяти и он будет 2 раза загружаться
    Если это один и тот же материал (всмысле один uobj) - то не будет дублирования. Если там 2 разных материала с копипастнутыми графами, то тогда да, будут две шейдермапы

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


    1. tzay Автор
      03.05.2022 02:33
      -1

      Про размер текстур: Это на самом деле капец глубокий вопрос!
      Во первых я говорю о видео памяти (VRAM), а не о оперативной памяти (RAM).
      Однако, я изучил вопрос глубже: действительно в нынешней реальности текстуры не обязаны быть квадратными, однако они все еще должны быть стонрой в степени 2-х.
      на это завязаны алгоритмы сжатия, выборки, интерполяции и генерации mip-карт. Да и многие другие на самом деле.
      Однако сейчас есть алгоритмы, например в DirectX 11 которые относительно спокойно работают с текстурами не в степени 2-х, но для некоторых операций/оптимизаций, на сколько мне известно, они все равно расширяют текстуру до степени 2-х.

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

      По поводу сжатия текстур. Почти все форматы сжатия используют тексели 4х4 пикселя, по этому что бы на текстуре нормально отработало сжатие ее стороны должны быть кратны - 4м. Это точно актуально для форматов: BC1-BC5
      Вот несколько статей, которые вы можете почитать что бы лучше понимать о чем я говорю, но конкретно интересующей нас инфы там мало:
      https://docs.microsoft.com/ru-ru/windows/win32/direct3d11/overviews-direct3d-11-devices-downlevel-intro?redirectedfrom=MSDN
      https://www.katsbits.com/tutorials/textures/make-better-textures-correct-size-and-power-of-two.php
      https://docs.microsoft.com/en-us/windows/win32/direct3d10/d3d10-graphics-programming-guide-resources-block-compression#virtual-size-versus-physical-size

      Про материалы и инстансы, опять же я говорю о видео памяти. Шейдерам для того что бы исполнятся на видеядрах нужно быть загруженным и вычисленным. Я сознательно опустил момент того, что шейдер нужно еще и вычислить перед использованием, заменив это просто термином "загрузка".
      Работает это (загрузка и вычисление 1 раз) только со статическими инстансами .В видео память шейдер с 2-х одинаковых материалов или с динамических инстансов будет загружен и вычислен 2 раза.
      Но в оперативной памяти будет висеть 1 объект материала.

      Кажется UE уже сам инстансит материалы при постановке их в слот в редакторе, но я нигде не нашел информации про это.

      Это обусловлено тем, что при изменении параметров материала шейдер должен быть так же изменен и как следствие вычислен заново. Про это можно почитать вот по этой ссылке: https://docs.unrealengine.com/5.0/en-US/instanced-materials-in-unreal-engine/ в разделе "Constant and Dynamic Instances"

      Про лоды отвечу так: А вы много видели в относительно современных играх мешей манее нескольких тысяч полигонов?
      Да и в целом в вопросе про лодирование я больше акцентирую внимание на снижение сложности шейдера, чем на количество полигонов.
      Однако! Основная задача лодирования мешей - решение проблемы перерисовок - quad overdraw. Пережевать же сколь угодно большое число вертексов геометрическим шейдером - фигня вопрос. Вот есть статья на эту тему: https://unrealartoptimization.github.io/book/pipelines/pixel/
      К примеру: если у вас будет стоять меш у которого мы видим 100 полигонов и на экране он будет занимать 2х2 пикселя - мы эти 4 пикселя будем перерисовывать столько раз, сколько полигонов мы видим, то есть - 100 раз. Это все будет в рамках одного дроукола, но все равно - 100 перерисовок, это оверкилл.


      1. AllexIn
        03.05.2022 07:53
        +2

        Это обусловлено тем, что при изменении параметров материала шейдер должен быть так же изменен и как следствие вычислен заново.

        Не совсем понимаю о чем вы.

        1. Как вы в материале измените параметры? Параметры вы можете менять только в инстансе. Соответственно даже если вы 10 раз используете материал в разных объектах - он будет загружен и скомпилирован один раз.

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

        Абсолютно безболезненно можно использовать один и тот же материал(не инстанс) в разных объектах. Он загрузится и скомпилируется один раз.


        1. tzay Автор
          03.05.2022 13:30

          Благодарю за указание не корректности, кажется лучше будет удалить или поменять часть про материалы и инстансы.


      1. SmallSnowball
        03.05.2022 10:28
        +1

        Ваша ссылка про фичелевел явно показывает, что начиная с 10 фичелевела карты спокойно поддерживают non-power-of-2. (что в общем-то покрывает буквально все десктоп карты, которые сейчас выпускают, потому что с 2008-2009 года карты с 10 фичелевелом массово пошли примерно)
        Про сжатие я в курсе, я поэтому и написал что "кратно 4х4 - это не совсем степень двойки". С мипами повеселее, потому что каждый мип надо сжимать bcшкой, что ведет к тому, что вроде как обе стороны текстуры должны быть степенями двойки, чтобы не напороться на кривой некратный двум ресемплинг. Но это не догма, никакого выделения "как 1024x1024" для текстуры 1000x100 не будет, у вас просто мипы начиная с какого-то индекса будут немножко кривоваты (т.к. ресемплинг их немножко размоет), но с памятью ничего плохого не случится. В целом это никак не мешает делать текстуры 2:1 (и в общем-то иногда и делают)

        С материалами не так, если у вас один uobj с шейдермапой, то он один раз будет скомпилирован и собран в соответствующие psoшки. Динамик инстансы да, рвут батчинг, но никаких дополнительных шейдеров не создают (хотя тут есть подводные камни в виде того, что вендоры в лице nvidia/amd могут для некоторых uniform параметров сами делать несколько специализаций и выбирать нужные шейдера в рантайме, но это настолько под капотом, что повлиять на это толком нельзя и увидеть без профилировщиков тоже)
        А вот обычные material instance кстати внезапно могут добавлять новые шейдера в шейдермапу (через статик свитчи в материалах)

        Про лоды и треугольники - мелкие пропсы регулярно могут быть меньше косаря треугольников. Ну т.е. возьмите какую-нибудь бочку / биллборд на заднем плане, вы при всем желании не сделаете ее больше тысячи треугольников (не, ну конечно есть умельцы, но в нормальной игре у вас такого не будет). Ну и всякая трава, но она как раз не в счет, т.к. инстансится обычно.

        С quad overdraw все тоже сложнее. Во первых есть подходы на visibility buffer, которые полностью игнорят этот ваш quad overdraw (привет, нанит, но нанит не единственный такой подход, есть еще call of duty, deus ex, horizon forbidden west с похожим подходом). Во вторых, если у вас 100 полигонов покрывают квад в 2х2 пикселя, то совершенно не факт что у вас будет 100 quad overdraw. Если треугольник настолько маленький, что не покрывает центр ни одного из 4 пикселей внутри квада, растеризатор не сгенерит для этого треугольника квад, и соответственно x100 овердро не будет. Будет сильно меньше (по кваду за каждый треугольник, покрывающий центр хотя бы одного пикселя в кваде). Хотя за вертексные преобразования и растеризацию вы все равно заплатите, поэтому делать так не надо конечно.


        1. tzay Автор
          03.05.2022 13:28

          Пожалуй я скажу что не так хорошо разбираюсь у вопросе на столь глубоком уровне, однако все равно убежден:

          1. Текстуры стоит держать стороной в степень 2ки, да не обязательно квадраты

          2. Лоды нужны, даже если объекты легковесны. Ну или их можно просто калить на самом деле


          1. AllexIn
            03.05.2022 15:32
            +2

            Правило про степень двойки оно как про испльзование пробелов при нейминге чего либо в ОС:
            Давно не актуально, но стоит придерживаться чтобы не отгрести в каком-нибудь эдж кейсе.

            У нас, например, пробелмы с переходом из одной VCS на другую, потому что кто-то бранч назвал используя пробел. Да, так было можно, но не нужно.
            Вот и с текстурами таже история:
            Используй степени двойки и точно ничего не проиграешь. Зато, условно, при порте на какое-нибудь внезапное железо можно НЕ получить артефакты или потери производительности на ровном месте.


  1. max_dark
    03.05.2022 02:48

    Как по мне самая большая проблема УйЁ - система сборки, которая несовместима ни с чем принятом в остальном мире.

    Проблемы начинаются с подключения сторонних библиотек(это больно)

    Особенно остро проблема подключения библиотек встает когда вы разрабатываете свою для интеграции со своими сервисами.

    Если бы УйЁ использовал для сборки CMake, то как по мне было бы не так больно...


    1. tzay Автор
      03.05.2022 03:21

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


      1. max_dark
        03.05.2022 03:49

        Документация(вернее её отсутствие) отдельная больная тема. Приходится зарываться в код движка вместо разработки функционала.

        И хорошо что код доступен(хотя и под странными условиями)


        1. tzay Автор
          03.05.2022 04:04

          О каких странных условиях вы говорите?)


          1. max_dark
            03.05.2022 04:10
            -1

            Я про скачивание исходников с GitHub по привязыванию аккаунта Ёпиков

            Вроде бы исходники доступны, но и OpenSource не являются...


    1. max_dark
      03.05.2022 04:55

      Нам нужны "странные" вещи а-ля nDisplay+SplitScreen по которым доков нет =\


      1. tzay Автор
        03.05.2022 13:33

        С nDisplay не работал. Возможно скоро придется, ченить напишу потом. А Split screen там просто все достаточно, вроде. Но тоже супер давно что то с ним делал


    1. loginmen
      03.05.2022 13:20

      CMake Это кроссплатформенность и независимость от компилятора по умолчанию. А UE привязан к компиляторам, на винде это MSVC, на линуксе Clang, на маке вроде тоже Clang либо XCode, но не суть. У движка очень много зависимостей, причем все из них качаются бинарниками. Бинарники от компилятора к компилятору не совместимы, если только речь не про dll (так как это PE). То тут получается 2 выхода, либо качать бинарники под все компиляторы (они и так уже качаются под все ос и весят на 4.27 38гб, а в 5 уже 60гб, лишние варианты еще раздуют объем исходников, почему не качать только то что нужно?) либо собирать все зависимости перед сборкой движка (зависимостей очень много и некоторые будут собираться дольше самого движка, у меня движок на не самом слабом компе собирается 1.5-2 часа. Все собирать это еще лишние проблемы, могут быть ошибки при сборке либо несовместимости с другими компиляторами).


      1. tzay Автор
        03.05.2022 13:24

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


  1. tzay Автор
    03.05.2022 21:20
    +1

    @SmallSnowballи @AllexInблагодарю вас, что помогли сделать статью лучше, я исправил части, на которые вы указали (про степень 2-х и материалы) и немного дополнил сверху.


  1. 4eyes
    04.05.2022 14:30

    Старайтесь не использовать BeginPlay для инициализации - вместо него есть много всего.

    А можно подробнее, почему?

    На первый взгляд, выглядит как место для инициализации по умолчанию.


    1. tzay Автор
      04.05.2022 21:33
      +1

      Идеологически BeginPlay - начало игрового процесса (что твой актер делает, когда игра началась для него). В идеале оно происходит после инициализации всего от чего актер зависит. Основная именно проблемная проблема - в мультиплеере бегин плей не всегда вызывается на клиентах.
      Так же актеры мог спавниться с отложенным бегин плеем (SpawnActorDeffered).
      Например при загрузке уровня стримингом актеры и компоненты создаются и инитятся группами, но бегин плей вызывают вместе.
      Что может нам говорить о том, что бегин плей должен быть легковесным.


      Функции же приведенные как альтернативы - вызываются всегда при создании объектов и предназначены для инициализации объектов.

      Если очень хочется то можно, конечно, но я бы не советовал.