Игра на ECS и как мы там живем
Привет, Хабр.
Не буду повторно тут описывать что такое ECS, для этого уже были хорошие статьи:
Постараюсь описать наш опыт и к чему мы пришли работая над игрой на ECS. Код приведен для LeoEcs Lite, но сами мысли очень общие. Буду рад критике и вашим мыслям.
Следует начать с начала, ведь чтобы к чему-то прийти хорошему нужны правильные цели. Постараюсь кратко описать что мы выбрали для себя.
Поддерживаемость
Сейчас в работе первая игра нашей студии. И, конечно, есть желание выпустить быстрее и увидеть заветные метрики. С другой стороны проект должен крепко стоять на своих ногах, нужно заложить стабильный фундамент. Он не должен рассыпаться от изменений. Я очень рад, что в моем желании найти золотую середину команда полностью меня поддержала.
Итеративность
Билды игры начались собираться буквально с первого прототипа. Даже когда у нас еще не был нанят QA, команда уже тестировала и играла в проект. Могла видеть как он меняется. Буквально можно было поставить версию месячной давности и ощутить прогресс.
С самого начала мы нещадно декомпозировали большие задачи чтобы в нашу недельную итерацию смог попасть хотя бы небольшой законченный этап.
Скорость разработки
Думаю все мы понимаем, что каждый месяц работы команды стоит дорого. А каждый месяц выхода за лимиты установленных сроков можно смело оценивать как х3 от суммы обычного месяца. Преследуя скорость мы точно не хотели стать авторами макаронного монстра, а после утонуть в багах.
Еще один важный для нас момент про скорость, но не самый очевидный — выгорание. Мы понимаем, что перед релизом нужно будет сделать рывок, поэтому важно дойти до него и дойти не прогоревшими до основания. Ну и лично для меня всегда было важно чтобы команда была довольно тем что она делать и как.
Чтобы добиться этого пункта мы постарались свести игровой код к максимально атомарным модулям. Чтобы работая параллельно над фичами можно было не ожидать, что сломаешь все, что рядом.
Теперь самое время описать те правила и инструменты, которые появились в команде при работе над проектом.
Иерархия должна описывать назначение
Мы поделили наши фичи и включили в них asm.def так, чтобы уже по вложенности можно было понять к какому блоку относится группа систем:
Пример иерархии кода в проекте
Читаемость кода
О, это очень древняя как мир мечта о эталонном коде и чистой архитектуре, но тут речь не про это. У нас все более приземленно. Сначала мы пошли стандартным путем. Появились описания для отдельных частей.
ECS Component — для чего он служит и что значит его наличие на Entity.
namespace Game.Ecs.UI.HealthBar.Components
{
using Leopotam.EcsLite;
#if ENABLE_IL2CPP
using Unity.IL2CPP.CompilerServices;
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
[Il2CppSetOption(Option.DivideByZeroChecks, false)]
#endif
///
/// Stores HealthBar entity and marks when entity linked with HealthBar view
///
[Serializable]
public struct HealthBarLinkComponent
{
public EcsPackedEntity Entity;
}
}
ECS System — через комментарии пояснение сложных мест в логике реализации и описания для чего нужна система:
///
/// Снимает из слота персонажа активное снаряжение
/// Начинает свое выполнение если получен DropEquipRequest
///
#if ENABLE_IL2CPP
using Unity.IL2CPP.CompilerServices;
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
[Il2CppSetOption(Option.DivideByZeroChecks, false)]
#endif
[Serializable]
public class DropActiveEquipSystem : IEcsRunSystem, IEcsInitSystem
Очень быстро мы поняли, что если мы будем использовать только это, то для составления цельного представления, придется перебрать все системы. Но наша цель упростить погружение в логику для новых участников команды и тех кто еще не трогал эту часть игры. И хотелось упростить задачу до уровня чтения страницы документации. Так у нас появилось понятие ECS Feature.
Наши соглашения по ECS
ECS Feature
Эта сущность определяет законченную игровую функциональность. Мы постарались разделить наш проект на достаточно атомарные фичи. Каждую мы поместили в свою ассембли и старались минимизировать количество зависимостей между фичами.
Каждая ECS Feature помещается в свой Assembly Definition, это еще раз «бьет по рукам» при попытке использовать зависимости без контроля.
Для отладки каждую из фичей можно в любой момент отключить через конфиг ECS.
Для фичи определяется ее Update Queue. Так например UI Feature выполняется в Late Update Queue.
Логика выполнения не должна зависит от порядка выполнения систем других фичей.
Зависимости между фичами направлены строго от более частных к более базовым. Так например фича характеристики никогда не будет знать о фиче механики урона.
Пример Ecs Feature
namespace Game.Ecs.UI.HealthBar
{
using Config;
using Systems;
using Cysharp.Threading.Tasks;
using Leopotam.EcsLite;
using UniGame.LeoEcs.Bootstrap.Runtime;
using UnityEngine;
public sealed class HealthBarViewFeature : BaseLeoEcsFeature
{
public override async UniTask InitializeFeatureAsync(EcsSystems ecsSystems)
{
// destroy healthBar view when owner entity gone
ecsSystems.Add(new HealthBarDestroySystem());
// create request to create healthBar view by checking HealthBarComponent without HealthBarViewComponent
ecsSystems.Add(new HealthBarCreateSystem());
// links HealthBarComponent owner entity and healthBar view entity, mark owner as linked
ecsSystems.Add(new HealthBarLinkSystem());
// change healthBar color based it's relation to player entity
ecsSystems.Add(new HealthBarColorSystem());
// update healthBar data from owner entity
ecsSystems.Add(new HealthBarUpdateSystem());
// show and hides healthBars based on unit target
ecsSystems.Add(new HealthBarUpdateSelectionTargetSystem());
}
}
}
С вводом понятия ECS Feature, многое встало на свои места. Так например еще на стадии декомпозиции фичи программист описывает ee псевдокод и HDL (high level design document). А уже после обсуждения и апрува приступает к реализации. Может показаться, что это лишь затягивает разработку и тратит время, но практика показывает обратное.
Предварительное планирование позволяет:
Понять идею реализации;
Увидеть где потенциально могут быть проблемы с производительность;
Проверить концепт на соответствие ГД;
Оценить как согласуется эта фича с другими, ведь 80% ошибок находятся в точках пересечения модулей
Человек намного меньше ошибается когда у него уже есть понимание что и как сделать;
Скорость реализации выше, ведь общий каркас уже задан и нужно лишь перенести в код;
Передача ссылки на Entity
Если компонент ссылается на другу Entity, то это должно быть сделано только через промежуточное звено EcsPackedEntity. EcsPackedEntity хранить в себе идентификатор целевой Entity и позволяет проверить жива ли entity.
EcsPool _somePool = world.GetPool();
public void Run()
{
forearch(var entity in _ecsFilter)
{
ref var someComponent = ref _somePool.Get(entity);
EcsPackedEntity target = someComponent.Target;
bool isAlive = target.UnpackEntity(_world, out var targetEntity);
}
}
Данное требования почти полностью убирает проблему обращения к «мертвым» сущностям по ссылкам и позволяет делать удобные запросы к игровым фичам.
Request
Это компонент, который служит для запроса одной фичи выполнить действие другой или запрос действия внутри фичи.
Такой компонент может быть создан из любой фичи, но обязательно как отдельная Entity. Время жизни такого компонента может быть меньше 1 го цикла пайплайна ECS. Как только он дойдет до системы, которая выполняет его в фиче, то будет обработан и после этого убит.
Например, реквест MakeDamageRequest может быть порожден внутри фичи Damage Feature и уже в этой же фиче обработан и убит.
Request по своей сути API для фичи в ECS. А отдельный нейминг позволяет читать это API даже на уровне структуры кода и понимать, что можно ожидать. Есть несколько требований к таким реквестам:
Request создается на отдельной Entity
Должен инкапсулирует всю необходимую информацию для выполнения
Любая ссылка на внешнюю Entity должна быть через EcsPackedEntity
Request удаляется только фичей для которой был порожден
Жизненный цикл реквеста заканчивается когда он будет обработан.
Реквест может прожить меньше 1 го полного цикла
SelfRequest
По мере использования реквестов мы заметили избыточность. У нас появились запросы вида:
public struct SomeRequest
{
public EcsPackedEntity Target;
public T1 Data1;
public T2 Data2;
…
public TN DataN;
}
Данные в запросе дублировали данные, которые находятся в компонентах Target. Это приводило к лишней работе и проверкам. Поэтому у нас появился еще один тип запросов — SelfRequest.
Все ограничения по времени жизни и использованию совпадают с Request
SelfRequest добавляется на Entity над которой должно быть выполнено действие.
Данные для выполнения преимущественно берутся с самого Entity на котором висит запрос
В коде проекта такой запрос выглядит так:
///
/// Destroy self entity request
///
#if ENABLE_IL2CPP
using Unity.IL2CPP.CompilerServices;
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
[Il2CppSetOption(Option.DivideByZeroChecks, false)]
#endif
[Serializable]
public struct DestroySelfRequest{}
Добавление этого типа запроса позволило упростить часть кода, а чем проще его читать, тем лучше.
Может возникнуть вопрос: «А почему бы нам не обойтись только SelfRequest»?
Попробую привести пример. Представьте, что мы реализуем систему урона. Пока 2 противника перекидываются ударами все будет работать. А теперь попробуем добавить в наш бой еще источники урона. Может возникнуть ситуация, что в один цикл обработки один из наших бойцов должен получить сразу 2 удара. Значит мы должны добавить на одну Entity сразу 2 компонента одного типа. Вот только сделать мы этого не сможем. И чтобы не придумывать сложную логику намного проще представить урон как отдельную сущность с компонентом урона.
Пример структуры компонент в проекте
Event
Еще одна наша производная от Component. Ивент это способ ECS Feature сообщить игровому миру о важных событиях внутри.
Соглашения по Event:
Гарантированно живет 1 цикл пайплайна и после этого будет убит. Это позволяет донести информация до всех заинтересованных систем. Например до системы аналитики.
Event добавляется всегда на новую сущность.
Event может порождать только одна Ecs Feature, она же его и убивает.
Мы стараемся не злоупотреблять ивентами и ориентироваться больше на данные мира и обычные компоненты. Но при этом эвенты стали удобным чтобы добавлять игровую аналитику или запустить проигрывание VFX смерти персонажа и т.д.
OwnerComponent
Представьте что у нас есть персонаж. С ним связано множество отдельных сущностей:
Экипировка
Характеристики
Эффекты, как положительные, так и отрицательные
И т.д
Все эти сущности объединяет одно — они должны быть уничтожены, если наш персонаж погибает. Это можно было бы реализовать для каждой Ecs Feature отдельно, но ведь это так лениво писать из раза подобное. Поэтому у нас появился OwnerComponent.
///
/// owner entity
///
#if ENABLE_IL2CPP
using Unity.IL2CPP.CompilerServices;
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
[Il2CppSetOption(Option.DivideByZeroChecks, false)]
#endif
[Serializable]
public struct OwnerComponent
{
public EcsPackedEntity Entity;
}
Соглашения по использованию:
У Entity может быть только один Owner
Если Owner убит, то зависимая Entity должна быть тоже убита
Compiler options
На некоторых примерах кода вы могли заметить использование атрибутов:
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
[Il2CppSetOption(Option.DivideByZeroChecks, false)]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
Прочитать подробно о них можно вот тут: Compiler Options
Если говорить кратко, то данные атрибуты отключают проверки в билде тем самым ускоряя выполнение кода. И как всегда это не «серебряная пуля» и есть свои недостатки. Самый главный из них, если в собранном с этими атрибутами коде произойдет Null Reference Exception, то ваше приложение продемонстрирует пользователю как выглядит краш. Поэтому у нас все точки использования атрибутов окружены дефайном — ENABLE_IL2CPP.
Этот дефайн включается/отключается в зависимости от типа сборки билда. QA чаще всего работают с выключенными атрибутами, чтобы было проще отловить проблему и снять локи из внутриигровой консоли. Тут важно не забыть объяснить QA, что ошибка у них это краш у игрока.
Инструменты
Конфиг для Ecs Feature
Что такое ECS Feature уже описал. Теперь покажу места их обитания. Все фичи у нас собраны в единый конфиг.
Пример конфига ECS Features
Сама ECS Feature может быть реализована и как Scriptable Object объект, так и «чистый» шарповый класс.
Существующая фича помещается в свою группу выполнения. Помимо стандартных для Unity: Update, FixedUpdate, LateUpdate у нас можно добавить кастомные группы выполнения. Это может быть полезно, если вы хотите обновлять UI не каждый Unity Update или есть фоновые процессы, которые нужно выполнять по их собственной логике.
Плагины для EcsSystem
Еще одна возможность — плагины. Мы хотели иметь возможность добавлять кастомные обработчики для систем.
Конфиг групп обновления для Ecs Features
На примере отрисовки Gizmos план был прост. Если любая Ecs System реализует интерфейс ILeoEcsGizmosSystem, то под редактором должна происходить отрисовка дополнительных редакторских элементов. Как пример таких элементов могут быть:
Зона агрессии монстров
Отрисовка сенсоров персонажей
Отображение целей для движение персонажа
А значит когда программист пишет фичу можно уже заложить инструментарий, который поможет проверить логику в контексте игры.
namespace UniGame.LeoEcs.Bootstrap.Runtime.Abstract
{
using Leopotam.EcsLite;
public interface ILeoEcsGizmosSystem
{
void RunGizmosSystem(IEcsSystems systems);
}
}
Провайдеры для Prefabs
Префабы важная составляющая работы с Unity. Поэтому хотелось сделать работу с ними удобной. И удобное не только программистам, но и художникам.
Тут мы подходили с следующими ожиданиями:
Любой настроенный художниками префаб можно было взять из редактора и добавить в игру без каких-либо лишних действий.
Работа с префабами шла максимально в контексте ECS мира
Префаб должен быть сконфигурировать и описывать то, чем он является в игровом мире.
Runtime добавление элементов
Следуя нашим целя в проекте появились Ecs Converters.
Пример конкертера из префаба в ECS Entity
Чистые шарповые конвертеры, которые можно добавлять в конфиг и уже переиспользовать этот конфиг. Например конвертируя разных монстров в Ecs Entity
Mono Converters — реализованные как компоненты GameObject, для того чтобы иметь возможность прокидывать зависимости и объекты самого префаба без лишних проблем
На примере игровых персонажей это позволило нам использовать префаб как внешний вид, а все логику и управление вынести в отдельный конфиг, которые можно применить при создании уже в Runtime.
Почему мы не сделали все конверторы через MonoBehaviour?
При увеличении компонент на GameObject увеличивается время на его десериализация в проекте. И увеличивается существенно. Этого не происходит если вы будете использовать SerializableReference (https://docs.unity3d.com/ScriptReference/SerializeReference.html) ссылки или просто ссылки на сериализуемые чистые классы.
Возможность группировки конвертеров вместе.
Шаринг конвертеров на разные сущности
MonoBehaviour увеличиваю сложность поддержки. На нашей практике скрипты для массового редактирования и валидации компонент намного проще реализовать для «чистых» классов.
Помимо конвертации провайдеры позволяют отображать данные ECS мира на префабе и только для режима редактора. Ниже пример отображения характеристик персонажа:
Инструмент для отображения характеристик персонажа на префабе
Entity Browser
Пока у вас все работает о многих вещах можно не задумываться. Самое же интересное начинается когда что-то работать перестает. И в таких ситуациях хочется понимать, что за компоненты сейчас на Entity и какие в этих компонентах данные. Для этих целей мы добавили Entity Browser. Интструмент, который позволяет в рантайме фильтровать по всем Entity в активном мире и просматривать/редактировать/удалять/фильтровать компоненты.
Пример использования Entity Browser
Game Editor
Даже приведенные выше инструменты могут вызывать вопросы у команды где их найти. Тут нам поможет ведение документации. Ее вообще лучше вести в проекте чем нет — ваш кэп. С другой стороны хотелось упростить поиск инструментов и в самом редакторе. Так мы добавили наш игровой редактор, которые собирает все основные инструменты в себе.
Шаблоны кода Rider
С этим все достаточно просто. Рутина как мы с вами знаем — зло, а поскольку вся команда пользуется Rider, то шаблоны подошли очень хорошо.
Чтобы синхронизировать шаблоны между клиентами подключили отдельный репозиторий для конфигурации IDE. Самим писать ничего не пришлось, в редакторе все уже есть из коробки.
Пример шаблона EcsComponent
namespace $NAMESPACE$
{
using System;
///
/// ADD DESCRIPTION HERE
///
#if ENABLE_IL2CPP
using Unity.IL2CPP.CompilerServices;
[Il2CppSetOption(Option.NullChecks, false)]
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
[Il2CppSetOption(Option.DivideByZeroChecks, false)]
#endif
[Serializable]
public struct $STRUCT$ {}
}
Пример использовая шаблона для Component
Конец, но это не точно
Спасибо за ваше внимание. На этом буду заканчивать. Деталей, как всегда, больше чем получилось рассказать. Например, вся логика нашего UI усправляется из ECS, надеюсь когда-нибудь доберусь чтобы рассказать как мы с этим живем. Если статья хоть чем-то оказалось полезной, значит все было не зря :).
P.S Ссылка на мой telegram канал о разработке игр