Тема: LeoECS
Показать сообщение отдельно
Старый 25.10.2020, 16:35   #1
Nex
Гигант индустрии
 
Аватар для Nex
 
Регистрация: 13.09.2008
Сообщений: 2,893
Написано 1,185 полезных сообщений
(для 3,298 пользователей)
LeoECS

Привет всем. Случайно вспомнил про булку, зашел и оказывается тут еще есть активность.

Вступление:
В некоторых темах увидел тред где Crystal с "процедурными" знаниями Блитза в очередной раз пытается сделать свой фаллаут, но только уже на Юнити и как ему сложно. Увидел как Кирпи4 спрашивает, как новичку влиться в геймдев. И вспомнилось былое время, когда у меня тоже не было знаний, но было много вопросов и как сложно было раскуривать ооп.
Но спасение есть. Когда-то давно на конференции Юнити представили Entitas фреймворк с ECS для Юнити и после этого эта тема всё больше и больше форсится, а многие мелкие студии уже давно перешли на ECS так как по уровню сложности это примерно как визуальное программирование и любой новичок крайне быстро может во всем разобраться и влиться в геймдевчик.

Самих ECS множество со своими плюсами, минусами и интеграцией в Юнити (DOTS/uECS). Но я напишу тут пример кода на LeoECS так как он максимально простой, позволяет делать "как хочешь" в отличии от DOTS/uECS, но при этом достаточно быстрый, изначально не привязан к Юнити и без кодогенерации как тот же Entitas.

Что к чему:
Краткая суть почему ECS это хорошо заключается в том, что практически не нужно уметь кодить, не нужно знать тонну паттернов и прочих сложностей. Все просто, как и в визуальном программировании, но только там соединяем ноды, а тут печатаем кусочки кода.
Так же использование ECS идеально подходит для небольших игр на мобилки типа Match3, но и большие тоже можно писать.

Сам ECS из себя представляет:
World - "мир" в котором всё крутится
System - системы, которые обрабатывают данные
Entity - фактически контейнер, который содержит в себе компоненты
Component - сам компонент представляющий собой структуру в которой храниться несколько значений.
*собственно Entity и Component это тоже самое, что и в Юнити GameObject и Component. Но только в Юнити свернули не туда

Ещё ECS можно представить просто как конвейер (World) по которому движутся коробки (Entity) в которых что-то лежит (Component), а рядом стоящие работники (System) берут эти коробки (Entity), что-то делают с ними или c их содержимым (Component), а затем или возвращают коробку (Entity) обратно на конвейер (World) или вовсе "выбрасывают в мусорку".

Особенность всего этого в том, что System, Entity и Component ничего не знают друг о друге и вся магия происходит благодаря System.EcsFilter<T, ..>, который берет все Entity в текущем "мире", сортирует их по наличию запрашиваемых компонентов на них и возвращает пользователю грубо говоря массив с Entity.

Ресурсы:
Для начала нам нужно скачать ECS и два модуля к нему для связи с Юнити (это кстати единственная сложность в использовании стороннего ECS).
LeoECS: https://github.com/Leopotam/ecs
(тут так же есть краткая документация, ссылки на другие модули, ссылки на примеры кода и на некоторые игры сделанные на этом ECS)
Unity integration: https://github.com/Leopotam/ecs-unityintegration
(это нужно просто для дебага ECS в редакторе Юнити)
Service Locator: https://github.com/Leopotam/globals
(это фактически синглтон для вызова ECS из классов MonoBehaviour)


Простой пример:
После добавления ресурсов с гитхаба в Юнити нужно нажать правой кнопной мышки например на папку Assets и выбрать "Create > LeoECS > Create Startup template". Это создаст MonoBehaviour класс с инициализацией ECS.
Данный юнити-компонент нужно добавить на игровой объект в сцене.
using Leopotam.Ecs;
using LeopotamGroup.Globals;
using UnityEngine;

namespace 
Example {
    
internal class EcsStartup MonoBehaviour {
        private 
EcsWorld world = default;
        private 
EcsSystems systems = default;

        
// Это нужно для интеграции с Юнити так как сам ECS изначально
        // никак не связан с ним.
        // В небольшом комьюнити по LeoECS принято использовать этот шаблон где:
        // GameData используется для реалтайм-данных так как System, Entity и Component 
        // ничего не знают друг о друге.
        // [System.Serializable]
        // public class GameData {
        //        public int shootCount = 0;
        //        public int collisionsCount = 0;
        // }
        
[SerializeField] private GameData gameData = new GameData();
        
        
// SceneData наследуется от MonoBehaviour и содержит в себе ссылки
        // на объекты сцены. Например это камера и какие-то
        // заранее созданные объекты. Я в этом примере заранее создал
        // игрока и сделал ссылку на его игровой объект.
        // public class SceneData : MonoBehaviour {
                // ссылка на объект игрока, который уже находится на сцене
        //         public PlayerView PlayerView;
        //        //public Camera mainCamera;
        //         //...
        // }
        
[SerializeField] private SceneData sceneData = default;
        
        
// Config наследуется от ScriptableObject и служит для хранения
        // всяких настроек, ссылок на префабы, которые в какой-то момент
        // нужно будет создать. Так же в нем можно хранить ссылки на
        // другие ScriptableObject с конфигами.
        // Именно этот класс сугубо только для удобства. Данные можно
        // так же классическим способом хранить и на MonoBehaviour игрока, врага.
        //[CreateAssetMenu]
        // public class Config : ScriptableObject {
        //         Данные игрока, которые можно изменять в реальном времени
        //        и которые сохраняться после выхода из игрового режима в редакторе
        //        public float PlayerMoveSpeed = 1f;
        //        public float PlayerRotateSpeed = 50f;
        //        [Space]
        //        public ProjectileView ProjectilePrefab = default;
        //        public float ProjectileStartSpeed;
        // }        
        
[SerializeField] private Config config = default;

        private 
void Start() {
            
world = new EcsWorld();
            
systems = new EcsSystems(world);

            
// Это пак Service Locator, который является аналогом синглтона. 
            // Для примера сохраним ссылку на реалтайм-данные
            
Service<GameData>.Set(gameData);

            
// Это пак Unity integration, который нужнен для дебага ECS в сцене. 
            // Так же именно это создаёт некоторое количество аллокаций так как 
            // для наглядности при добавлении Entity или компонентов изменяет 
            // имя скрытых игровых объектов, которые отображаются в дебаге.
            // Скрин в аттаче
#if UNITY_EDITOR
            
Leopotam.Ecs.UnityIntegration.EcsWorldObserver.Create(world);
            
Leopotam.Ecs.UnityIntegration.EcsSystemsObserver.Create(systems);
#endif
            // Это хендл для систем. Тут в список добавляются системы.
            
systems
                
// Сам процесс добавления систем. Если не добавлять систему или если 
                // что-то закомментить, то у нас просто пропадёт часть функционала 
                // без каких либо ошибок. Один из серьезных плюсов.
                
.Add(new PlayerInitSystem())
                .
Add(new PlayerInputSystem())
                .
Add(new PlayerMoveSystem())
                .
Add(new PlayerRotateSystem())

                
// Удаляет все экземпляры указанного компонента со всех Entity и со 
                // всех тут указанных систем.
                // Этот метод специфичен и иногда при неправильном добавлении 
                // систем создаёт проблемы. Так как если сначала в список 
                // добавить систему с обработкой компонента, а ниже добавить 
                // систему с генерацией компонента, то естественно на следующем 
                // цикле этот компонент уже будет удален и система с обработкой
                // компонента уже не будет работать.
                
.OneFrame<PlayerInputEvent>()

                
// Это Dependency injection данных во все системы в текущем EcsSystems
                
.Inject(gameData)
                .
Inject(sceneData)
                .
Inject(config)
                
                
// Инициализация EcsSystems
                
.Init();
        }

        
// Тут вызывается цикл EcsSystems. Если нужно использовать FixedUpdate,
        // то нужно создать еще одну систему и по аналогии вызвать.
        // Важно помнить, что Entity с компонентами хранятся в EcsWorld и добавление
        // новых или удаление EcsSystems повлияет только на функционал.
        
private void Update() {
            
systems?.Run();
        }

        
// Очистка системы и "мира".
        // При добавлении новых EcsSystems в мир нужно будет не много переделать
        // код дабы сначала удалить все EcsSystems, а потом и сам "мир".
        
private void OnDestroy() {
            if (
systems != null) {
                
systems.Destroy();
                
systems null;
                
world.Destroy();
                
world null;
            }
        }
    }

В идеале система должна выполнять минимально количество кода. Из-за этого количество систем может быть и 10 и 100 и соответственно лучше сразу называть их понятным для себя образом.

Первая система называется у нас PlayerInitSystem. В ней мы создаем Entity и связываем с игроком на сцене.
using Leopotam.Ecs;
using UnityEngine;
internal class PlayerInitSystem IEcsInitSystem {
    
// Тот самый Dependency injection.
    // Для EcsWorld выполняется автоматически, а все остальное нужно добавлять 
    // руками через .Inject(..)
    
private readonly EcsWorld ecsWorld = default;
    private 
readonly SceneData sceneData = default;

    
// IEcsInitSystem.Init для вызова на старте игры или при вызове EcsSystem.Init()
    
public void Init() {
        
// Получаем ссылку на PlayerView
        
var view sceneData.PlayerView;
        
// Создаем новый Entity. Все Entity находятся в пуле. Если свободный Entity
        // уже есть, то тогда берется из пула
        
var entity ecsWorld.NewEntity();

        
// Добавляем в Entity компонент PlayerRef и сохраняем ссылку на PlayerView
        // из которого можно будет получить ссылку на transform и другие компоненты
        
entity.Get<PlayerRef>().View view;
        
// В сам PlayerView тоже сохраняем ссылку на Entity
        
view.Entity entity;
    }
}    

public class 
PlayerView MonoBehaviour {
    public 
EcsEntity Entity;    
    
// Фактически PlayerView не нужен, достаточно только в ECS-компоненте
    // сохранить ссылку на transform игрока.
    // Но при таком подходе потом при вызове любого MonoBehaviour события
    // допустим OnCollisionEnter можно будет сразу добавить нужный компонент
    // на Entity игрока
    // private void OnCollisionEnter(Collision other) {
    //     Entity.Get<PlayerCollideEvent>();
    // }
}
    
internal struct PlayerRef {
    public 
PlayerView View;

Далее у нас система PlayerInputSystem в которой обрабатываем Input. Вообще нужно делать по другому, но для примера будет достаточно.
using Leopotam.Ecs;
using UnityEngine;
internal class PlayerInputSystem IEcsRunSystem {
    
// Тут в EcsFilter<T> мы указываем с каким компонентом нам нужны Entity.
    // Компонентов можно задать больше
    // Так же можно использовать EcsFilter<T, ..>.Exclude<T, ..> для исключения
    // определенных компонентов из результата
    
private readonly EcsFilter<PlayerReffilter = default;

    
// IEcsRunSystem.Run этот метод вызывается в каждом цикле
    
public void Run() {
        
// В данном случае это нужно только для того, что бы не вызывался
        // лишний раз Input и создание Vector2 если вдруг у нас пустой фильтр
        
if(filter.IsEmpty())
            return;
        
        var 
Input.GetAxisRaw("Horizontal");
        var 
Input.GetAxisRaw("Vertical");
        var 
direction = new Vector2(hv).normalized;

        
// Проходим по всем элементам. Тут за foreach можно не переживать
        // Код с Input'ом можно перенести и сюда, но это будут лишние
        // вычисления если у нас например в фильтре будет не 1, а 1000 Entity
        
if (direction.sqrMagnitude 0f) {                
            foreach (var 
i in filter) {
                
// Получаем ссылку на Entity данной итерации и добавляем
                // компонент, который заканчивается на Event. Таким образом
                // мы только для себя обозначаем, что этот компонент должен
                // быть удален после использования всеми системами.
                // В данном случае он будет удален в конце главного цикла
                // через метод .OneFrame<PlayerInputEvent>()
                
filter.GetEntity(i).Get<PlayerInputEvent>().Value direction;
            }
        }
    }
}

internal struct PlayerInputEvent {
    public 
Vector2 Value;

Система PlayerMoveSystem будет двигать все Entity с определенным количеством компонентов
internal class PlayerMoveSystem IEcsRunSystem {
    
// Как видим тут мы фильтруем по наличию компонентов PlayerRef
    // и PlayerInputEvent, которые в данном примере должны быть только
    // у Entity игрока.
    
private readonly EcsFilter<PlayerRefPlayerInputEventfilter = default;
    
// DI конфига
    
private readonly Config config = default;

    public 
void Run() {
        
// Тут уже не нужны никакие доп. вычисления типа Input как в прошлой системе
        
foreach (var i in filter) {
            
// Обратите внимание на "ref". Так как у нас компоненты 
            // являются "struct", мы должны получать ссылку на них
            // именно через ref иначе мы получим копию, которая
            // ничего не изменит и удалится сразу же после выхода
            // из этого метода.
            // Через Get1, Get2, GetN мы получаем компонент из Entity.
            // Нумерация компонентов такая же как в EcsFilter
            
ref var playerRef ref filter.Get1(i);
            
ref var inputEvent ref filter.Get2(i);

            
// Получаем ссылку на transform игрока через PlayerView,
            // который до этого сохранили в компоненте PlayerRef
            // Так же получаем из конфига скорость игрока,
            // которую можно менять в реальном времени.
            // Если у нас будет 1000 игроков в фильтре,
            // то скорость изменится для всех.
            
var tr playerRef.View.transform;
            
tr.position += tr.forward inputEvent.Value.config.PlayerMoveSpeed Time.deltaTime;
        }
    }

Следующая система фактически копи-паст предыдущей и может возникнуть вопрос нафига этот лишний код? А ответ тот же, что и был выше - если отключить систему или если закоментить её подключение в коде, то в игре просто пропадёт функционал, который содержится в системе и никаких ошибок не будет.
internal class PlayerRotateSystem IEcsRunSystem {
    private 
readonly EcsFilter<PlayerRefPlayerInputEventfilter = default;
    private 
readonly Config config = default;

    public 
void Run() {
        foreach (var 
i in filter) {
            
ref var playerRef ref filter.Get1(i);
            
ref var inputEvent ref filter.Get2(i);

            
// Все тоже самое, но только мы тут разворачиваем игрока
            // Конечно можно объединить в одну, но тогда мы теряем
            // ту самую гибкость ECS
            
var tr playerRef.View.transform;
            
tr.Rotate(Vector3.upinputEvent.Value.config.PlayerRotateSpeed Time.deltaTime);
        }
    }

Изображения
Тип файла: png ECS Debug.PNG (9.0 Кб, 593 просмотров)
Тип файла: png Игрок.PNG (77.2 Кб, 593 просмотров)
(Offline)
 
Ответить с цитированием
Эти 3 пользователя(ей) сказали Спасибо Nex за это полезное сообщение:
Evgen (26.10.2020), pax (26.10.2020), Randomize (25.10.2020)