ECS框架浅析

关于ECS

为何需要ECS

在传统的面向对象设计中(OOP),进行框架设计首先就要进行类的层次结构,而在这一过程中就会出现多重继承困难、层次结构不易改动的现象。

而且游戏开发中一种比较常见的现象就是,由于操作和数据没分离,A对B造成了伤害,是A去打了B,还是B受到了A的伤害,函数应该放在哪里?ECS就没有这种疑惑,数据存放在Component类、逻辑计算直接由System负责

这和传统的面向对象或是 Actor 模型是截然不同的。OO 或 Actor 强调的是对象自身处理自身的业务,然后框架去管理对象的集合,负责用消息驱动它们。而在 ECS 中,每个系统关注的是不同的对象集合,它处理的对象中有共性的切片。

ECS的基本概念

Component:由数据组成

Component是数据的集合,只有变量,只有Get,Set相应函数或者是对应的属性,Component之间不能直接通信

struct Component{
	//子类将会有大量变量,以供System利用
}

在定义一个Component时最好先搞清楚它的数据是System数据还是Entity数据。如果是System的数据,一般设计成单例Component。例如存放玩家键盘输入的 Component ,全局只需要一个,很多 System 都需要去读这个唯一的 Component 中的数据。

Entity: 由多个Component组成

Entity就可以代表我们的游戏物体,比如一个正方体就包含着Position,Rotation等一系列Component,Unity中的GameObject就是这样的存在

拥有全局唯一的ID来标识自身

class Entity{
	Int32 ID;
    //通过观察者模式将自己注册到System可以提升System遍历的速度,因为只需要遍历已经注册的entity
	List components;
}

Entity需要遵循立即创建和延迟销毁原则,销毁放在帧末执行,不然很容易空引用

System:由纯逻辑组成

System用来制定游戏的运行规则,只有函数,没有变量。System之间的执行顺序需要严格制定。System之间不可以直接通信

一个 System只关心某一个固定的Component组合,这个组合集合称为tuple。

各个System的Update顺序要根据具体情况设置好,System在Update时都会遍历所有的Entity,如果一个Entity拥有该System的tuple中指定的所有Component实例,则对该Entity进行处理。看到这里你可能会想每次update都遍历Entity,会不会太耗费时间,因此前面我们推荐使用观察者模式来注册

class System{
    public abstract void Update();
}

class ASystem:System{
    Tuple tuple;

    public override void Update(){
        for(Entity entity in World.entitys){
            if(entity.components中有tuple指定的所有Component实例){
                //do something for Components
            }
        }
    }
}

World:整个游戏世界

游戏通常情况下只会有一个world,但是守望先锋等游戏为了死亡回放等游戏内容创建了两个world(后面还会有很多次提到守望先锋,因为他是最早使用ECS框架的)

class World{
    List systems;                   //所有System
    dictionary entitys;      //所有Entity,Int32是Entity.ID

    //由引擎帧循环驱动
    void Update(){
        for(System sys in systems)
            sys.Update();
    }
}

ECS的优点

任意增删

因为Component之间不可以直接访问,System之间也不可以直接访问,System和Component在设计原则上也不存在耦合。

对于System来说,Component只是放在一边的数据,Component提供的数据足够就update,数据不够就不update。所以随时增删任意Component和System都不会导致游戏崩溃报错

比如一个单位中了不能移动的Debuff,那么我们只需要去掉这个单位的Move Component就行了,如果是玩家那么再去掉一个Input Component就可以了

优化性能

因为数据都被统一存放到Component中,所以如果能够在内存中以合理的方式将所有Component聚合到连续的内存中,这样可以大幅度提升cpu cache命中率

Unity,在传统模式下,我们在场景中创建一个Cube,上面会有Transform,MeshRenderer,Collider等组件,而这些组件在内存中的排放都是无序的,这就会降低我们的缓存命中率

每个内存块我们称之为Chunk,ECS会将符合Chunk对应组合的Entity放在该Chunk当中。一个Chunk中,内存地址是连续的,大小固定为16KB

避免不必要开销

守望先锋在GDC中移除不活跃用户时,AFK 处理系统遍历所有同时具备连接组件、输入组件等组件的对象,根据最近输入事件产生的时间强制下线。AI 控制的机器人,由于没有连接组件,就根本不会遍历到,也就不用在其上面浪费计算资源了

ECS的实际运用

需要遵循的原则

  1. 设计并不是从Entity开始的,而是应该从System抽象出Component,最后组装到Entity中。
  2. 设计的过程中尽量确保每个System都依赖很多Component去运行,也就是说System和Component并不是一对一的关系,而是一对多的关系。
    • System和Component的划分很难在一开始就确定好,一般都是在实现的过程中看情况一步一步地去划分System和Component。
  3. System尽量不改变Component的数据。
    • 可以读数据完成的功能就不要写数据来完成。因为写数据会影响到使用了这些数据的模块,如果对于其它模块不熟悉的话,就会产生Bug。如果只是读数据来增加功能的话,即使出Bug也只局限于新功能中,而不会影响其它模块。这样容易管理复杂度,而且给并行处理留下了优化空间

处理一些复杂问题的常见手法

同类问题的处理方式

​ 许多 System 中很可能会处理同一类问题,涉及的 Component 类型是相同的。如果这个有共性的问题只涉及一个 Entity ,那么直观的方法是设计一个 System ,迭代,逐个把结果计算出来,存为 Component 的状态,别的 System 可以在后续把这个结果作为一个状态读出来就可以了。

​ 但如果这个行为涉及多个 Entity ,比如在不同的 System 中,都需要查询两个 Entity 的敌对关系。我们不可能用一个 System 计算出所有 Entity 间的敌对关系,这样必然产生了大量不必要的计算;又或者这个行为并不想额外修改 Component 的状态,希望对它保持无副作用,比如我想持续模拟一个对象随时间流逝的位置变化,就不能用一个 System 计算好,再从另一个 System 读出来。

​ 这样,就引入了 Utility 函数的概念,来做上面这种类型的操作,再把 Utility 函数共享给不同的 System 调用(也就是单例组件)。为了降低系统复杂度,就要求要么这种函数是无副作用的,随便怎么调用都没问题,比如上面查询敌对关系的例子;要么就限制调用这种函数的地方,仅在很少的地方调用,由调用者小心的保证副作用的影响,比如上面那个持续位置变化的过程。

​ 如果产生状态改变这种副作用的行为必须存在时,又在很多 System 中都会触发,那么为了减少调用的地方,就需要把真正产生副作用的点集中在一处了。这个技巧就是推迟行为的发生时机。就是把行为发生时需要的状态保存起来,放在队列里,由一个单独的 System 在独立的环节集中处理它们。集中在一起推迟到当前帧的末尾或下一帧的开头来做。

网络问题

ECS 要解决的最复杂,最核心的问题,或许还是网络同步。我认为这也是设计一个状态和行为严格分离的框架的主要动机。因为一个好的网络同步系统必须实现预测、有预测就有预测失败的情况,发生后要解决冲突,回滚状态是必须支持的。而状态回滚还包括了只回滚部分状态,而不能简单回滚整个世界。

​ ECS 框架在这件事上可以做到只去回滚和重算相关的 Component ,一个 System 知道哪些 Entity 才是它真正关心的,该怎么回退它所关心的东西。这样开发的复杂度就减少了。游戏本身是复杂的,但是和网络同步相关的影响到游戏业务的 System 却很少,而且参与的 Component 几乎都是只读的。这样我们就尽可能的把这个复杂的问题和引擎其它部分解耦。

你可能感兴趣的:(c#,unity)