Привет, Хабр.

Не буду повторно тут описывать что такое 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
   /// <summary>
   /// Stores HealthBar entity and marks when entity linked with HealthBar view
   /// </summary>
   [Serializable]
   public struct HealthBarLinkComponent
   {
       public EcsPackedEntity Entity;
   }

}

ECS System - через комментарии пояснение сложных мест в логике реализации и описания для чего нужна система:

/// <summary>
/// Снимает из слота персонажа активное снаряжение
/// Начинает свое выполнение если получен DropEquipRequest
/// </summary>
#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<SomeComponent> _somePool = world.GetPool<SomePool>();

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 на котором висит запрос

В коде проекта такой запрос выглядит так:

/// <summary>
/// Destroy self entity request
/// </summary>
#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.

/// <summary>
/// owner entity
/// </summary>
#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 Features

Сама ECS Feature может быть реализована и как Scriptable Object объект, так и “чистый” шарповый класс. 

Существующая фича помещается в свою группу выполнения. Помимо стандартных для Unity: Update, FixedUpdate, LateUpdate у нас можно добавить кастомные группы выполнения. Это может быть полезно, если вы хотите обновлять UI не каждый Unity Update или есть фоновые процессы, которые нужно выполнять по их собственной логике.

Плагины для EcsSystem

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

  • Отключать систему в Runtime по галке в конфиге

  • Иметь возможно отрисовывать Gizmos для систем

  • И т.д

Конфиг групп обновления для Ecs Features
Конфиг групп обновления для 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 добавление элементов
Runtime добавление элементов

Следуя нашим целя в проекте появились Ecs Converters. 

Пример конкертера из префаба в ECS Entity
Пример конкертера из префаба в 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
Пример использования Entity Browser

Game Editor

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

Шаблоны кода Rider

С этим все достаточно просто. Рутина как мы с вами знаем - зло, а поскольку вся команда пользуется Rider, то шаблоны подошли очень хорошо.

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

Пример шаблона EcsComponent

namespace $NAMESPACE$
{
    using System;

    /// <summary>
    /// ADD DESCRIPTION HERE
    /// </summary>
#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
Пример использования шаблона для Component

Конец, но это не точно

Спасибо за ваше внимание. На этом буду заканчивать. Деталей, как всегда, больше чем получилось рассказать. Например, вся логика нашего UI управляется из ECS, надеюсь когда-нибудь доберусь чтобы рассказать как мы с этим живем. Если статья хоть чем-то оказалось полезной, значит все было не зря :).

P.S Ссылка на мой telegram канал о разработке игр

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


  1. MultiTeemer
    18.06.2023 11:36
    +1

    Отличная статья! Особенно понравилось разделение кода на фичи и идея реквестов - сам так делаю на своих проектах.


  1. Leopotam
    18.06.2023 11:36
    +1

    Очередная статья об использовании фреймворка, о котором никто не знает и пользуются полтора человека. Пара замечаний:

    1. Атрибут "[Il2CppSetOption(Option.DivideByZeroChecks, false)]" бессмысленно указывать, это его значение по умолчанию согласно документации.

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


  1. SH42913
    18.06.2023 11:36

    Отличная статья! Огромное спасибо!