一个无框架的ECS实现(Entity-Component-System)

原文地址:https://zhuanlan.zhihu.com/p/32787878


咱们先从一切的起源说起——

只要是游戏,大多都会出现这样一个Enity-Manager系统。因为游戏本质就是大量实体行为(Enity)以及他们之间的交互(Manager)。

但很显然,一个游戏不可能只有两个类。随着逻辑的膨胀,出于各种原因都会进行逻辑的拆分。而比起继承,复合的灵活性更强,所以最后基本都会变成这样一个状态:

其实一般的游戏到这个状态就可以了,偶尔也会有一些继承关系穿插其中。但在实际的逻辑编写过程中,经常会出现一些恼人的两择问题:

同一段逻辑,我到底是应该放在Component,还是Manager里呢?

因为这两个东西是相互依赖的,放哪儿其实都一样,而到底放那里才合适往往并不是那么容易判断的。因此过一阵子,即使是代码的编写者也不记得到底是放在Component还是Manager里了,得两边都找一次才可以。假如,Component和Manager并不是简单的两级关系,而是多级,就更好玩儿了。

通常,与多个Component相关的逻辑代码,放在Manager更合适。但是假如把只和一个Component相关的代码放在Manager,也只是看起来有点像静态方法,有点蠢,但并没有大碍。所以,经过权衡之后,开发者决定把Component的所有逻辑全部移动到对应的Manager上,以消除这种二择难题,这就产生了System:


写作Component,实为Data

这样移动逻辑之后,由于Data(Component)的依赖关系变得很简单,开发者又发现,其实Data胡乱拆分也没有关系,System也可以不受限制根据需要操作多个Data的数据,于是就变成了这样:


这就是ECS(Entity-Component-System)

实际上,这只是一个正常的架构优化,最主要的“特色”是将Component的逻辑全部移动到了System上,其他部分都是顺理成章的结果。


基本特征如下:

  • System是唯一承载逻辑的地方
  • Data(阿呸,是Component)不允许有逻辑,对外依赖就更不能有了
  • Entity首先是一个Data,但本质上是个多个Data的桥梁,用于标识它们属于同一物体。在不同的数据结构下,它甚至可以仅仅是一个int。
  • 在允许的情况下,System并不直接依赖Entity,因为并不需要。System直接依赖Data也有利于清晰判断依赖关系。
  • 至于System之间的相互依赖关系,和以前Component,Manager之间的相互依赖还是一样的。该怎么处理就怎么处理,这是ECS之外的问题。

一些意外的收获

  • 由于Data被拆散了,不容易遇到读入整个对象却只使用其中一个属性的情况(比如我们常见的读入一个Vector3却只使用一个x),有利于Cache(不过一般不会抠到这个份儿上)
  • 由于Data被拆散了,状态同步的功能可以直接放在Data上,同步逻辑会变得简单。
  • 由于Data和System之间依赖关系明确,交叉较少,对线程安全非常友好。在摩尔定律单核失效的现在,多线程会变得越来越重要


下面是个示例,玩家控制的两个球会吞吃屏幕中的点变大,球之间会相互推挤保证不重叠,包含一个吞食运动动画。


flashyiyi/A-Simple-ECS-Example​github.com

这个示例里,并没有框架代码。里面那个DList/DItem是个处理foreach时增删元素的东西,用普通的List只要用for倒序遍历也是一样的。


首先是Component,也就是些纯数据类。数据类固定包含一个Entity的链接让它们能联系在一起。

public class BaseComponent : DItem
{
    public Entity entity;
}

public class PositionComponent : BaseComponent
{
    public Vector2 value;
}

public class SizeComponent : BaseComponent
{
    public float value;
}

public class SpeedComponent : BaseComponent
{
    public Vector2 value;
    public float maxValue;
}

public class ColorComponent : BaseComponent
{
    public Color value = Color.white;
}

public class TeamComponent : BaseComponent
{
    public int id;
}

//与Unity组件的桥接
public class GameObjectComponent : BaseComponent
{
    public GameObject gameObject;
    public Transform transform;
    public SpriteRenderer spriteRenderer;
}

//临时特效型Component
public class EatingComponent : BaseComponent
{
    public GameObjectComponent go;
    public PositionComponent target;
    public Vector2 startOffest;
    public Vector2 endOffest;
    public float dur = 0.2f;
    public float endTime;

    //仅操作数据的方法可以存在
    public float GetLifePercent()
    {
        return 1f - (endTime - Time.time) / dur;
    }

    public void Start()
    {
        endTime = Time.time + dur;
    }

    public Vector2 GetCurPosition()
    {
        return target.value + Vector2.Lerp(startOffest, endOffest, GetLifePercent());
    }
}


Entity部分,这里并没有维护Component数组,而是以“写死”的方式把固定的Component创建出来并保存在字段里。因为背后并没有框架,并不需要提供框架需要的数据。而即使背后有框架,为了性能通常也会像这样把每个Component取出来“写死”放在一个固定的地方,其实也没啥太大的区别。

这样做的缺陷是无法动态增加Component,但是在项目逻辑代码内,需要动态Component的情况又有多少呢?真需要动态Component的时候(比如Buff),再加一个专门的数组管理也不迟。

public class Entity : DItem
{
    public GameObjectComponent gameObject;
    public PositionComponent position;
    public SizeComponent size;
    public ColorComponent color;
    public TeamComponent team;
    public Entity()
    {
        gameObject = new GameObjectComponent() { entity = this };
        position = new PositionComponent() { entity = this };
        size = new SizeComponent() { entity = this };
        color = new ColorComponent() { entity = this };
        team = new TeamComponent() { entity = this };
    }
}

public class MoveAbleEntity : Entity
{
    public SpeedComponent speed;
    public MoveAbleEntity() : base()
    {
        speed = new SpeedComponent() { entity = this };
    }
}


System部分其实近似于静态类,仅仅保留一个Root对象的链接以避免出现单例。而整个系统中,也只有System才有权限访问Root对象。

目前所有的数据列表都保存在GameWorld这个Root对象中,通过Root对象也可以访问到其他的System。

可以看到,下面这些类只有EatSystem直接依赖了Entity,那是因为它涉及到了Entity本身的增删。其他的System都避免了对具体Entity的依赖,而只依赖零散的Component。

虽然看上去有点蠢,但这样在Entity拥有多个版本的时候,System并不需要关心自己操作的具体是哪一个,也就是Entity实际上拥有了“无限的多态特性”。

public class SystemBase
{
    public GameWorld world;
    public SystemBase(GameWorld world)
    {
        this.world = world;
    }
}

//移动
public class MoveSystem : SystemBase
{
    public MoveSystem(GameWorld world) : base(world) { }
    public void Add(SpeedComponent speed)
    {
        world.speeds.DelayAdd(speed);
    }

    public void Remove(SpeedComponent speed)
    {
        world.speeds.DelayRemove(speed);
    }

    public void Update(SpeedComponent speed, PositionComponent position, SizeComponent size)
    {
        position.value += speed.value * Time.deltaTime;
        if (position.value.x > world.screenRect.xMax - size.value)
        {
            position.value.x = world.screenRect.xMax - size.value;
            speed.value.x = 0f;
        }
        else if (position.value.x < world.screenRect.xMin + size.value)
        {
            position.value.x = world.screenRect.xMin + size.value;
            speed.value.x = 0f;
        }
        if (position.value.y > world.screenRect.yMax - size.value)
        {
            position.value.y = world.screenRect.yMax - size.value;
            speed.value.y = 0f;
        }
        else if (position.value.y < world.screenRect.yMin + size.value)
        {
            position.value.y = world.screenRect.yMin + size.value;
            speed.value.y = 0f;
        }
    }
}

//操控
public class InputSystem : SystemBase
{
    public InputSystem(GameWorld world) : base(world) { }
    public void Update(SpeedComponent speed, PositionComponent position)
    {
        Vector2 delta = (Vector2)world.mainCamera.ScreenToWorldPoint(Input.mousePosition) - position.value;
        speed.value = Vector2.ClampMagnitude(speed.value + delta.normalized * Time.deltaTime, speed.maxValue);
    }
}

//吞食逻辑
public class EatSystem : SystemBase
{
    public EatSystem(GameWorld world) : base(world) { }
    public void Update(PositionComponent sourcePosition, SizeComponent sourceSize, PositionComponent targetPosition, SizeComponent targetSize, Entity target)
    {
        float sizeSum = sourceSize.value + targetSize.value + 0.05f;
        if ((sourcePosition.value - target.position.value).sqrMagnitude < sizeSum * sizeSum)
        {
            sourceSize.value = Mathf.Sqrt(sourceSize.value * sourceSize.value + targetSize.value * targetSize.value);
            Kill(target, sourcePosition);
        }
    }
    public void Kill(Entity food, PositionComponent sourcePosition)
    {
        world.eatingSystem.CreateFrom(food.gameObject, food.position, sourcePosition);

        world.entitySystem.RemoveEntity(food);
        world.entitySystem.AddRandomEntity();
    }
}

//圆推挤
public class CirclePushSystem : SystemBase
{
    public CirclePushSystem(GameWorld world) : base(world) { }
    public void Update(PositionComponent pos1, SizeComponent size1, PositionComponent pos2, SizeComponent size2)
    {
        Vector2 center = Vector2.Lerp(pos1.value, pos2.value, size1.value / (size1.value + size2.value));
        Vector2 offest = pos1.value - center;
        float offestSqrMagnitude = offest.sqrMagnitude;
        float sqrRadius = size1.value * size1.value;
        if (offestSqrMagnitude < sqrRadius)
        {
            float offestMagnitude = Mathf.Sqrt(offestSqrMagnitude);
            if (offestMagnitude == 0)
                offestMagnitude = 0.01f;
            float pushMul = Mathf.Min(size1.value - offestMagnitude, (1 - offestMagnitude / size1.value) * Time.deltaTime * 10f);
            pos1.value += offest.normalized * pushMul;
        }
    }
}


这里要专门提下这个EatingSystem。它对应的是特殊的Component,和Entity无关,是在小球被吃掉时临时创建并管理它被吃掉的过程动画,操作的对象也仅仅是GameObjectComponent ,在它被创建之后,原本的Entity的生命周期其实已经结束了。

Entity唯一的作用是连接相关的Component,如果你仅仅关心它的一部分内容,就只需要引用那部分内容。GameObjectComponent就是那个小球的一张皮,我们不需要小球身上的其他逻辑,借用这张皮播一个死亡动画就可以了。

另外EatingSystem对应的EatingComponent,本身也没有对应的Entity,因为它并不需要和其他的Component连接起来。

时刻记住,在ECS里,System直接相关的是Component,而非Entity。没有什么比Entity的地位更低了。

//吞食动画
public class EatingSystem : SystemBase
{
    public EatingSystem(GameWorld world) : base(world) { }
    public void Update(EatingComponent e)
    {
        e.go.transform.position = e.GetCurPosition();
        if (Time.time >= e.endTime)
        {
            world.eatings.DelayRemove(e);
            world.gameObjectSystem.Remove(e.go);
        }
    }

    public void CreateFrom(GameObjectComponent gameObject, PositionComponent source, PositionComponent target)
    {
        gameObject.entity.gameObject = null;//解除和原entity的关系

        EatingComponent comp = new EatingComponent();
        comp.go = gameObject;
        comp.target = target;
        comp.startOffest = source.value - target.value;
        comp.endOffest = Vector2.Lerp(source.value, target.value, 0.5f) - target.value;
        comp.Start();
        world.eatings.DelayAdd(comp);
    }
}


这是这个系统如何和Unity的可视部分连接的。因为System不能保持状态,Unity的对象是存在专门的Component里的。除非为了接受事件,尽量不要往Untity的GameObject上添加MonoBehaviour脚本。除了性能上的考虑(Update涉及到反射),不需要加的东西干嘛非要加上去呢。

//和Unity显示部分的桥接
public class GameObjectSystem : SystemBase
{
    public GameObjectSystem(GameWorld world) : base(world) { }
    public void Add(GameObjectComponent e, PositionComponent position, SizeComponent size, ColorComponent color)
    {
        e.gameObject = new GameObject("Entity");
        e.transform = e.gameObject.transform;
        e.transform.localScale = Vector2.one * 0.001f;
        e.spriteRenderer = e.gameObject.AddComponent<SpriteRenderer>();
        e.spriteRenderer.sprite = UnityEditor.AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Knob.psd");
        Update(e, position, size, color);
    }

    public void Remove(GameObjectComponent go)
    {
        GameObject.Destroy(go.gameObject);
        go.transform = null;
        go.gameObject = null;
        go.spriteRenderer = null;
    }

    public void Update(GameObjectComponent go, PositionComponent position, SizeComponent size, ColorComponent color)
    {
        go.transform.position = position.value;
        go.transform.localScale = Vector2.one * Mathf.MoveTowards(go.transform.localScale.x, size.value * 11f, Mathf.Max(0.01f, Mathf.Abs(go.transform.localScale.x - size.value)) * 10f * Time.deltaTime);
        go.spriteRenderer.color = color.value;
    }

    public void SetToTop(GameObjectComponent go)
    {
        go.gameObject.AddComponent<SortingGroup>().sortingOrder = 1;
    }
}


最后是这个EntitySystem。它的逻辑都是最基本的增删Entity的过程。本来按道理System应该尽量少访问Entity,但Entity总得有个人管理才对啊。本来这段是放在GameWorld里的,犹豫了下还是单独列成了System,算是个特殊的System吧。而它的依赖关系也是最多的。

//增删物体和场景初始化
public class EntitySystem : SystemBase
{
    public EntitySystem(GameWorld world) : base(world) { }

    public void AddEntity(Entity e)
    {
        world.entitys.DelayAdd(e);
        world.gameObjectSystem.Add(e.gameObject, e.position, e.size, e.color);
    }

    public void RemoveEntity(Entity e)
    {
        world.entitys.DelayRemove(e);
        if (e.gameObject != null)
            world.gameObjectSystem.Remove(e.gameObject);
    }

    public void AddRandomEntity()
    {
        Entity e = new Entity();
        e.size.value = 0.025f;
        e.team.id = 0;
        e.position.value = new Vector2(Random.Range(world.screenRect.xMin + e.size.value, world.screenRect.xMax - e.size.value), Random.Range(world.screenRect.yMin + e.size.value, world.screenRect.yMax - e.size.value));
        AddEntity(e);
    }

    public void AddMoveAbleEnity(MoveAbleEntity e)
    {
        this.AddEntity(e);
        world.playerEntitys.Add(e);

        world.moveSystem.Add(e.speed);
        world.gameObjectSystem.SetToTop(e.gameObject);
    }

    public void InitScene()
    {
        for (int i = 0; i < 50; i++)
        {
            AddRandomEntity();
        }

        for (int i = 0; i < 2; i++)
        {
            MoveAbleEntity playerEntity = new MoveAbleEntity();
            playerEntity.position.value = Vector2.zero;
            playerEntity.size.value = 0.05f;
            playerEntity.color.value = Color.yellow;
            playerEntity.speed.maxValue = 1f;
            playerEntity.team.id = 1;
            playerEntity.position.value = new Vector2(Random.Range(-0.1f, 0.1f), Random.Range(-0.1f, 0.1f));
            AddMoveAbleEnity(playerEntity);
        }
    }
}


最后就是GameWorld部分了。首先它是一个MonoBehaviour,因为Unity程序的入口必须是MonoBehaviour。它的作用就是保存游戏里的全部对象,因为它们总得有一个地方保存。在它的Update方法里,决定不同数据的遍历逻辑,以及System的执行方式。ECS每个部分基本都很零散,总需要一个地方将它们连接在一起。

Gameworld应该是整个项目中交叉修改最多的一个文件,但也只有这个文件会这样。由于所有System同时也依赖了Gameworld,导致它的可替换性很弱,这也是这个无框架的系统最大的弱点了。

如果希望System可以多项目复用(或者更广范围的单元测试),需要对GameWorld做一些解耦处理,比如使用Event系统让System间通信,以及对数据提供通用化的存储方式,还有个办法是把System对GameWorld依赖的部分接口化……

嘛,需要的时候就做这样的修改就好了,毕竟要功能总有代价。但毕竟也有大量的团队并不需要这样做。耦合低,自然程序就会复杂,复杂就会导致成本,处理不好还会有性能损失和可靠性降低。关键在于,这种问题并不是ECS独有的,任何时候都存在这个权衡问题,没必要在这里讨论。如果要做到对GameWorld的解耦(同时保证可维护性和效率),代码量是肯定要增加的,也会让我这个示例看起来和别人写的没啥区别。

好在要处理的耦合度问题也只有System - GameWorld而已,起码问题被集中了。

(此外在Update内我有意试图多写了几种遍历方式,其实并不需要这样,仅作抛砖引玉用)

public class GameWorld : MonoBehaviour
{
    public DList<Entity> entitys;
    public DList<SpeedComponent> speeds;
    public DList<MoveAbleEntity> playerEntitys;

    public DList<EatingComponent> eatings;

    public EntitySystem entitySystem;
    public MoveSystem moveSystem;
    public GameObjectSystem gameObjectSystem;
    public InputSystem inputSystem;
    public CirclePushSystem circlePushSystem;
    public EatSystem eatSystem;
    public EatingSystem eatingSystem;

    public Camera mainCamera;
    public Rect screenRect;

    void Start ()
    {
        if (Camera.main == null)
        {
            GameObject go = new GameObject("Camera");
            mainCamera = go.AddComponent<Camera>();
        }
        else
        {
            mainCamera = Camera.main;
        }
        mainCamera.clearFlags = CameraClearFlags.Color;
        mainCamera.backgroundColor = Color.black;
        mainCamera.orthographic = true;
        mainCamera.orthographicSize = 1f;
        mainCamera.nearClipPlane = 0f;

        screenRect = Rect.MinMaxRect(-mainCamera.aspect, -1f, mainCamera.aspect, 1f);

        entitys = new DList<Entity>();
        playerEntitys = new DList<MoveAbleEntity>();
        speeds = new DList<SpeedComponent>();
        eatings = new DList<EatingComponent>();

        entitySystem = new EntitySystem(this);
        moveSystem = new MoveSystem(this);
        gameObjectSystem = new GameObjectSystem(this);

        inputSystem = new InputSystem(this);

        eatSystem = new EatSystem(this);
        eatingSystem = new EatingSystem(this);
        circlePushSystem = new CirclePushSystem(this);

        entitySystem.InitScene();
        ApplyDelayCommands();//执行延迟增删数组内容的操作
    }
    
    public void ApplyDelayCommands()
    {
        entitys.ApplyDelayCommands();
        playerEntitys.ApplyDelayCommands();
        speeds.ApplyDelayCommands();
        eatings.ApplyDelayCommands();
    }

    void Update ()
    {
        //遍历所有Entity并执行所有相关System
        foreach (Entity item in entitys)
        {
            if (item.destroyed)
                continue;

            gameObjectSystem.Update(item.gameObject, item.position, item.size,item.color);
        }
        //多对多关系
        foreach (MoveAbleEntity player in playerEntitys)
        {
            if (player.destroyed)
                continue;

            inputSystem.Update(player.speed,player.position);
            foreach (Entity item in entitys)
            {
                if (item == player || item.destroyed)
                    continue;

                if (item.team.id == 0) //是食物,执行吃逻辑
                    eatSystem.Update(player.position, player.size, item.position, item.size, item);
                else if (item.team.id == 1) //是玩家控制角色,执行圆推挤逻辑
                    circlePushSystem.Update(player.position, player.size, item.position, item.size);
            }
        }
        //单独遍历某些Component
        foreach (SpeedComponent speed in speeds)
        {
            if (speed.destroyed)
                continue;

            Entity enity = speed.entity;
            moveSystem.Update(speed, enity.position, enity.size);
        }
        //和Entity无关的Component
        foreach (EatingComponent item in eatings)
        {
            if (item.destroyed)
                continue;

            eatingSystem.Update(item);
        }

        ApplyDelayCommands();
    }
}


最后:

  • 本文的写作理由是:偶尔看到有人说“ECS只适合大项目”,这是一个对这个观点的反驳。确实某些ECS的“写法”很适合大项目不适合小项目,但这是那个“写法”导致的,和ECS本身其实没啥关系。事实上,在同样的“写法”下,ECS相比非ECS还是有不少优点的,而且代价并不高。
  • ECS的代价,个人认为是“Component无逻辑产生的反直觉会让工程师极端不适应”,“基本废掉了继承的多态特性,导致继承无用”。有一种大众言论是“ECS是反OOP的”,如果把OOP仅仅理解成“封装,继承,多态”,这种说法确实没错,因为多态才是三者最重要的部分,而ECS确实把继承的多态特性毁掉了。但是把“OOP”理解成“面向对象编程”,那“ECS”则依然是面向对象编程的,因为System依然是对象。毁灭了继承的“多态”虽然可惜,但“多态”还是有很多其他方式可以实现的(比如说利用策略模式)。ECS本身并不会造成项目的可维护性降低。
  • ECS虽然解决了一些两择难题,但当一段逻辑放在这个System上可以,放另一个System也可以,还是会出现两择难题。放到Util类上是能解决,但用不用Util,依然是个两择难题。
  • ECS还有一个显而易见的问题。由于逻辑和状态在两个不同的类里,状态要能访问就只能全public,这多多少少还是有些隐患。
  • 虽然都要求System不能持有状态,但是假如一个状态数组和System强相关,或者仅允许这个System访问,是否应该允许将数组放在System内以限制可见度?(Component同理),对于ECS的逻辑限制到底需要遵守到什么程度还需要摸索。设计模式是用来解决问题的,而非用来遵守的。总想着遵守,最终反而会解决不了问题。这就是所谓的“过度设计”了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值