什么是ECS框架?讲解 + 实战带你入门ECS框架

一、什么是ECS框架

ECS,即 Entity-Component-System(实体-组件-系统) 的缩写,其模式遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。

实体与组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。

ECS基本结构

在这里插入图片描述
先有一个World,它是系统和实体的集合,而实体就是一个ID,这个ID对应了组件的集合。组件用来存储游戏状态并且没有任何行为,系统拥有处理实体的行为但是没有状态。

二、ECS中的E、S、C

1. Entity:实体

实体只是一个概念上的定义,指的是存在你游戏世界中的一个独特物体,是一系列组件的集合。为了方便区分不同的实体,在代码层面上一般用一个ID来进行表示。所有组成这个实体的组件将会被这个ID标记,从而明确哪些组件属于该实体。由于其是一系列组件的集合,因此完全可以在运行时动态地为实体增加一个新的组件或是将组件从实体中移除。比如,玩家实体因为某些原因(可能陷入昏迷)而丧失了移动能力,只需简单地将移动组件从该实体身上移除,便可以达到无法移动的效果了。

样例

  • Player(Position, Sprite, Velocity, Health)
  • Enemy(Position, Sprite, Velocity, Health, AI)
  • Tree(Position, Sprite)

注:括号前为实体名,括号内为该实体拥有的组件

2. Component:组件

一个组件是一堆数据的集合,可以使用C语言中的结构体来进行实现。它没有方法,即不存在任何的行为,只用来存储状态。一个经典的实现是:每一个组件都继承(或实现)同一个基类(或接口),通过这样的方法,我们能够非常方便地在运行时动态添加、识别、移除组件。每一个组件的意义在于描述实体的某一个特性。例如,PositionComponent(位置组件),其拥有x、y两个数据,用来描述实体的位置信息,拥有PositionComponent的实体便可以说在游戏世界中拥有了一席之地。当组件们单独存在的时候,实际上是没有什么意义的,但是当多个组件通过系统的方式组织在一起,才能发挥出真正的力量。同时,我们还可以用空组件(不含任何数据的组件)对实体进行标记,从而在运行时动态地识别它。如,EnemyComponent这个组件可以不含有任何数据,拥有该组件的实体被标记为“敌人”。

根据实际开发需求,这里还会存在一种特殊的组件,名为 Singleton Component (单例组件),顾名思义,单例组件在一个上下文中有且只有一个。具体在什么情况下使用下文系统一节中会提到。

样例:

  • PositionComponent(x, y)
  • VelocityComponent(X, y)
  • HealthComponent(value)
  • PlayerComponent()
  • EnemyComponent()

注:括号前为组件名,括号内为该组件拥有的数据

3. System:系统

理解了实体和组件便会发现,至此还未曾提到过游戏逻辑相关的话题。系统便是ECS架构中用来处理游戏逻辑的部分。何为系统,一个系统就是对拥有一个或多个相同组件的实体集合进行操作的工具,它只有行为,没有状态,即不应该存放任何数据。举个例子,游戏中玩家要操作对应的角色进行移动,由上面两部分可知,角色是一个实体,其拥有位置和速度组件,那么怎么根据实体拥有的速度去刷新其位置呢,MoveSystem(移动系统)登场,它可以得到所有拥有位置和速度组件的实体集合,遍历这个集合,根据每一个实体拥有的速度值和物理引擎去计算该实体应该所处的位置,并刷新该实体位置组件的值,至此,完成了玩家操控的角色移动了。

注意,我强调了移动系统可以得到所有拥有位置和速度组件的实体集合,因为一个实体同时拥有位置和速度组件,我们便认为该实体拥有移动的能力,因此移动系统可以去刷新每一个符合要求的实体的位置。这样做的好处在于,当我们玩家操控的角色因为某种原因不能移动时,我们只需要将速度组件从该实体中移除,移动系统就得不到角色的引用了,同样的,如果我们希望游戏场景中的某一个物件动起来,只需要为其添加一个速度组件就万事大吉。

一个系统关心实体拥有哪些组件是由我们决定的,通过一些手段,我们可以在系统中很快地得到对应实体集合。

Singleton Component

上文提到的 Singleton Component (单例组件) ,明白了系统的概念更容易说明,还是玩家操作角色的例子,该实体速度组件的值从何而来,一般情况下是根据玩家的操作输入去赋予对应的数值。这里就涉及到一个新组件InputComponent(输入组件)和一个新系统ChangePlayerVelocitySystem(改变玩家速度系统),改变玩家速度系统会根据输入组件的值去改变玩家速度,假设还有一个系统FireSystem(开火系统),它会根据玩家是否输入开火键进行开火操作,那么就有 2 个系统同时依赖输入组件,真实游戏情况可能比这还要复杂,有无数个系统都要依赖于输入组件,同时拥有输入组件的实体在游戏中仅仅需要有一个,每帧去刷新它的值就可以了,这时很容易让人想到单例模式(便捷地访问、只有一个引用),同样的,单例组件也是指整个游戏世界中有且只有一个实体拥有该组件,并且希望各系统能够便捷的访问到它,经过一些处理,在任何系统中都能通过类似world->GetSingletonInput()的方法来获得该组件引用。

UtilityFunction

系统这里比较麻烦,还存在一个常见问题:由于代码逻辑分布于各个系统中,各个系统之间为了解耦又不能互相访问,那么如果有多个系统希望运行同样的逻辑,该如何解决,总不能把代码复制 N 份,放到各个系统之中。UtilityFunction(实用函数) 便是用来解决这一问题的,它将被多个系统调用的方法单独提取出来,放到统一的地方,各个系统通过 UtilityFunction 调用想执行的方法,同系统一样, UtilityFunction 中不能存放状态,它应该是拥有各个方法的纯净集合。

样例:

  • MoveSystem(Position, Velocity)
  • RenderSystem(Position, Sprite)

注:括号前为系统名,括号内为该系统关心的组件集合

三、实战项目

内容

场景里放个球,键盘上下左右控制位移。

实战

  1. 新建一个工程,确认你的版本支持ECS,我这里用的是2018.3
    修改PlayerSetting->Other Setting->Configuration->Scripting Runtime Version为4.x
    接下来在工程的Package目录(Assets同级)放入manifest文件以启用ECS框架

  2. 在ECS框架中,原本类似MonoBehavior的职能被拆分成纯数据以及操作数据的系统,所以你将会看到大量xxxComponent,xxxSystem的类。比如接下来我们就要建立一个只带移动方向的移动组件、修改移动方向的输入系统以及控制移动的移动系统。

  3. 新建几个C#文件:

表示移动数据的MoveComponent.cs

using Unity.Entities;
using UnityEngine;

public class MoveComponent : MonoBehaviour
{
    public Vector3 moveDir;
}

表示移动系统的MoveSystem.cs

using Unity.Entities;
using UnityEngine;
 
public class MoveSystem : ComponentSystem
{
    public struct Filter
    {
        public Transform tf;  
        public MoveComponent moveComponent;
    }
 
    protected override void OnUpdate()
    {
        foreach (var entity in GetEntities<Filter>())
        {
            Vector3 pos = entity.tf.position + entity.moveComponent.moveDir * Time.deltaTime * 3;
            entity.tf.position = pos;
        }
    }
}

表示输入系统的InputSystem.cs

using Unity.Entities;
using UnityEngine;

public class InputSystem : ComponentSystem
{
    struct Data
    {
        public ComponentArray<MoveComponent> moveArray;
    }

    [Inject] private Data _data;
    protected override void OnUpdate()
    {
        for (int i = 0; i < _data.moveArray.Length; ++i)
        {
            _data.moveArray[i].moveDir = new Vector3(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"), 0);
        }
    }
}
  1. 场景中新建一个球体,添加一个GameObjectEntity组件以及我们刚刚新建的MoveComponent组件

原理

在上面的例子里,输入系统每帧检测输入,把输入赋给移动组件,_data是包含所有MoveComponent的数组,这个数组是通过[Inject]属性标签注入进去的。这个注入的标签很有意思,它会匹配好里面你需要的组件给你放进去,它的原理我们找时间再深入研究。

而移动系统就更简单了,就是遍历所有移动组件,然后根据moveDir去移动它对应的transform。

大概你也注意到了InputSystem和MoveSystem的遍历方式是不一样的,这是为了演示可以有不同的遍历方式。而且要注意,上面例子里MoveComponent继承自MonoBehavior,如果它继承自IComponentData,它就只能是一个struct,而struct并不能够像MoveSystem那样去遍历,而且也不能直接用组件方式添加到一个GameObject上了。

继承自IComponentData的好处自然是让纯数据的组件以及系统与GameObject分离,试想一下你可以同时跑100w个移动的对象,但这些对象却不一定需要一个GameObject,这就是ECS带来性能提升的一大原因。

你可能觉得奇怪,球上只添加了两个组件,怎么就移动了,MoveSystem既没new出来也没拖到什么对象上,谁在激活这些系统?答案是世界,当然这是另一个话题了,在本篇里你只需要知道有个“世界”在运行你写的System就够了,只要你写了个系统继承ComponentSystem,它就会被执行。

参考文章

Unity框架探索——ECS架构Entitas篇之基础概念
Unity——十分钟上手Unity ECS教程

  • 40
    点赞
  • 85
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

就一枚小白

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值