• 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利用
    }
    
    • 1
    • 2
    • 3

    在定义一个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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    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
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    World:整个游戏世界

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

    class World{
        List systems;                   //所有System
        dictionary entitys;      //所有Entity,Int32是Entity.ID
    
        //由引擎帧循环驱动
        void Update(){
            for(System sys in systems)
                sys.Update();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    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 几乎都是只读的。这样我们就尽可能的把这个复杂的问题和引擎其它部分解耦。

  • 相关阅读:
    hand_mysql
    【es6】教程
    C++ 多态
    Nginx 笔记(五)nginx+keepalived高可用集群(主从+双主)
    Spring Boot 2 (十):Spring Boot 中的响应式编程和 WebFlux 入门
    电容笔哪个牌子好?2022年电容笔十大品牌排行榜
    C#使用OpenCv(OpenCVSharp)图像轮廓多边形逼近和轮廓最小矩形实例
    计算机毕设 大数据全国疫情数据分析与3D可视化 - python 大数据
    python自动化测试全栈之ApiFox批量运行脚本生成报告
    2008-2020年31省农业保险保费收入相关数据/农业保险收入数据/农业保险支出数据/保险总支出数据
  • 原文地址:https://blog.csdn.net/jkkk_/article/details/127718110