[转]ECS的说明和在游戏后端的应用

随笔3个月前发布 号高地
29 0 0

 转自 知乎 Focux

ECS近年来已然成为游戏开发中比较热门的一种架构模式,最近被大家所熟识并热烈讨论,还是源于GDC2017,《守望先锋》针对它们的ECS架构进行的一次技术分享。针对FPS,MOBA这类的竞技游戏,ECS架构有着得天独厚的优势。下面我们先简单地介绍一下什么是ECS。

E — Entity 实体,本质上是存放组件的容器
C — Component 组件,游戏所需的所有数据结构
S — System 系统,根据组件数据处理逻辑状态的管理器

 

这里需要强调一下,Componet组件只能存放数据,不能实现任何处理状态相关的函数,而System系统不可以自己去记录维护任何状态。说的通俗点,就是组件放数据,系统来处理。这么做的好处,就是为了尽可能地让数据与逻辑进行解耦。与此同时,一个良好的数据结构设计,也会以增加CPU缓存命中的形式来提升性能表现。但我认为,推动ECS流行的更深层次原因,是它的解耦理念,性能上还只是其次。

试想一下,你在开发一款多人实时在线竞技游戏,无论是吃鸡也好,农药也罢,日益堆积的变动需求与越来越多的状态维护肯定是开发人员所需面对的主旋律。也许游戏设计之初,策划会信誓旦旦地跟你说,咱们要整个5v5对战的MOBA游戏,就跟王者农药差不多,不过咱们牛逼的地方在于英雄可以变成汽车,就像变形金刚那样

假设你用面向对象设计中的”类-继承“模式,肯定会优先设计出一个“英雄”类来,它可能会有不同的技能和武器,当然这需要通过策划来配置,我们则根据这些不同的配置数据,来实例化出一个个独立的英雄。同时,这个类还要包含“变身”的功能,变成汽车后,“人”的一些行为会被限制,比如无法开枪射击,但是移动速度会大大增加,物理耐性提升等等。

当你辛辛苦苦设计好满意的游戏架构后,策划又兴冲冲地跑过来,阐述他的新点子:英雄变成汽车状态后,应该有不同的形态表现,有的2个轮子的、4个轮子的,也有履带式的,最好还能支持没有轮子的概念车!在策划看来,这种仅仅调整一些配置性的东西,实现起来难度应该不大。但是对于开发者来说,移动的动作,车轮的印记特效等等,都需要重新考虑一番,好好地重构。

没过几天,策划又鬼魅般地出现,提出了新的需求:我体验了下游戏原型,只是变身汽车的话游戏画面不够立体,打击感也不强,你看能不能再加个变身飞机的机制,最好能海陆空全方位战斗

但游戏的版本迭代不断试错都是客观存在的事实,是无法逃避的。随着需求的不断累加,最初的那个“英雄类”会越来越臃肿。倘若你的项目中有多个程序员进行协作开发,那么恭喜你,代码的维护成本会指数级的增加!每个人都必须对英雄的方方面面了若指掌,否则一个不当的改动就可能造成毁灭性的灾难。

这个时候,ECS架构就体现出了它的优势

与传统的“类-继承”奉行的“我是什么”不同,基于组件化的ECS架构更强调的是“我有什么”,是一种组合优先的编程模式。使用组合而非继承,会使你的代码更具灵活性。还是上面的例子,针对游戏的玩法,我们会构建出一个英雄的Entity实体类,它更像一个空盒子,可以在创建英雄Entity实例的时候赋予它一个ID作为唯一标识。当我们将这个实体放到world下,也许什么也看不见,什么也做不了,这是因为它现在还什么数据都没有。此时就需要根据游戏的需求,来设计出不同的组件填充到这个实体当中。注意,应尽可能地保证组件设计上的扁平化,会让你的模块结构更加清晰,也大大增加了CPU缓存命中的概率

举个例子,常见的组件包括而不仅限于

渲染组件 :英雄的顶点、材质等数据,保证我们能正确地渲染到world中
位置组件 :记录着实体在这个world的真实位置
特效组件 :不同的时机,可能会需要播放不同的粒子特效以增强视觉感受

此外,根据策划的各种奇葩需求,还可以衍生出不同的功能性组件,本质上都是数据的集合,之后会交由 System 来进行各种状态修改与逻辑计算。比如,想要一个英雄既能变成汽车又能变成飞机,我们可以设计出 Wheel 和 Wing 两个组件,存储数据的同时也表明不同实体的对应功能或身份。当然对应着的是处理该组件的System,一个 FlightSystem 可以去关注那些持有 Wing 的实体。确切点说,FlightSystem 其实只需要关注 Wing 组件就足够了,它不应该关心是哪个实体持有这个组件,只要能修改 Wing 的状态就足矣

这样,就实现了我们经常说的解耦

将复杂的游戏拆解成不同的逻辑处理单元 (System) ,而每个逻辑处理单元只关心那些向它注册监听的数据,其他数据一概不管。并且最主要的是,System 是不保存状态的,Component 才是状态的真正持有者。刚开始的时候,也许会很不适应,总想着在 System 里加点什么标识,好方便地进行状态回溯或者复用。这时应该警惕起来,你所设计的 System 职责是否单一,组件持有的数据是否过于复杂。将一个复杂的模块拆解成若干个相对简单的单元,不失为明智的选择。下面就是我们基于ECS而设计的新的游戏架构。

[转]ECS的说明和在游戏后端的应用

 

然而,现实总是残酷的。如果一个游戏真要这么简单,或许ECS也就没什么存在的价值了。仅就《守望先锋》分享所知,它们游戏中光 System 就上百个,并且为了保证 System 不保存状态,在游戏帧更新时,System 执行的时序就有了限制。并且很多情况下,很多System关心的组件只有一个(如输入事件),于是就有了Singleton Component。个人觉得,由于不同游戏的不同特质,某些情况下都很难去严格遵循ECS的架构约束,但毕竟架构是为人服务的,而不仅仅是束缚。在我们深刻理解了ECS的思想后,针对实际需要来做一些变通也是未尝不可的。就拿我参与开发的一款轻MOBA类的多人对战游戏为例,采用的网络通讯方式是protobuf加状态同步,伤害、状态等判定结果几乎都是放在服务器端来处理,客户端主要就是根据每一帧接收的网络消息来处理相应的数据、更新状态。由于客户端使用的语言是Lua,因此会使用一个table数据结构来保存游戏中注册的 System 实例,在帧循环遍历这个注册表,按照顺序依次执行每个 System 的Update函数。System 会处理自己内部维护的组件池,里面放的是注册进来希望被处理的组件,从而根据这些组件的数据来进行一些逻辑上的操作。解析protobuf数据后,每条协议消息发来的数据其实就是组件所要更新的,但由于服务器端并没有采用ECS这种设计模式,数据设计上也肯定会有些出入的地方,于是需要客户端来解析转换下。为此,引入了Driver的概念。首先,会有若干个表格来存放我们创建了的不同Entity 实例,每个Entity都会持有一个ID。服务器端下发的消息中总会包含某些实体ID,这样处理不同逻辑的 Driver 就会根据这些ID来找到它所需修改数据的Entity,再从Entity找出相关的 Component 组件,将proto消息里的数据更新给这个组件即可,剩下的工作就交给 System 了。至此,我们的ECS设计变种成了这个样子:

[转]ECS的说明和在游戏后端的应用

 

上述设计中,由于多了一层Driver,并且游戏使用的是状态同步机制,因此帮助 System 分担了很多工作。System 仅仅是批量处理 Component 状态的管理者,每一帧遍历系统组件池里的所有组件 (此时的组件已经是 Driver 更新好数据了的) ,我们也可以根据自己的需要来设定刷新间隔,对于一些不需要在每个帧刷新都执行 Update 函数的 System ,可以降低它们的更新频率从而节约一些性能开销。而 Driver 层面,只是数据的一道“搬运工”,负责更新给 System 能够识别的组件的持有数据。当所在团队改用ECS后,起初都很不习惯这种新的编程模式,总会不知不觉中切换回“类-继承”的编程思路。但随着项目的推进,ECS所带来的一些优势变得愈发明显。首先,就是降低了团队协作成本。往昔的项目经历中后期时,总会出现某些又臭又长的 God Class ,甚至会出现一些功能重复的模块,同一个功能的函数被实现了两次!这种情境下,代码的维护成本可想而知。而在ECS编程模式下,每名开发人员,只需要关心自己负责的模块即可,System 很好地隔离模块之间的耦合。ECS也并非尽善尽美,随着系统不断地开发与扩展,会发现难免有时 System 要处理的数据过于复杂,很难只用一个单一组件就能表示,或是之前所说的只有一个 Component 会被 System 监控的Singleton情况十分常见。这就考验开发者的权衡取舍能力:如果一味细化 System 的种类,虽能保证模块与组件数据之间的解耦,但却无形中增加了 System 的维护成本,使得原本比较简单的逻辑变得复杂起来,难以维护。另一种做法是,建立组件之间的关联,可以是组件内部持有另一个组件的指针,也可以是几个组件组合成一个新的组件。但这样做就牺牲掉了组件设计上的扁平化原则。综上所述,世界上没有两全其美的好事,我们经常要去做决策、去权衡每种做法的利弊。一味刻板地循规蹈矩也是不可取的,越来越多的团队开发衍生出了新的ECS变种。但只要我们真正地理解了ECS的主旨与精髓,不断探索与改进,找到适合自己项目的最佳平衡点,它一定会成为助你高效开发的利器。

=============================================================================

 

[转]ECS的说明和在游戏后端的应用

 ==========================================================================================

 

以下转自知乎南盼:https://zhuanlan.zhihu.com/p/559776142

 ECS作为一种经典的GamePlay架构,凭借与oop截然不同的数据和逻辑分离的架构设计,使其在游戏客户端领域拥有诸多独有优势,深受很多客户端开发同学的推崇。本文从后端开发的视角出发,期望能借鉴ECS的思想来解决游戏后端开发中遇到的问题。

结论前置

与大部分架构先定义对象,再根据对象的功能扩充数据不同。ECS模型基于“数据定义对象”的思想,首先根据功能需要定义不同类型的组件(即数据)。再将相互关联的组件组成一个实体(即对象)。系统(即业务逻辑)只关注组件,只要一个实体拥有系统所依赖的组件,那么这个系统就可以应用在该实体上。

本文借鉴ECS模型,提出一个适用于后台有状态服务的开发架构:

结构分为框架层和业务层。
所有的数据以组件形式存储,实体只用于表示组件之间的关联关系。
系统是表达业务逻辑的纯方法。系统以组件作为参数,且必须显式的将其依赖的组件类型注册到框架层。
业务层只关注系统,组件以及系统对组件的依赖关系(即业务逻辑,数据 和 执行业务逻辑需要哪些数据)。
框架层负责管理所有的组件,并根据外部请求为系统准备其所依赖的组件。

[转]ECS的说明和在游戏后端的应用

系统 – 组件 – 实体

这种设计使得框架获得了对业务层极细粒度的治理能力。在此加持下,框架可以做到:

组件级别的数据管理能力。组件级别的按需加载能力,可以降低单个实体的实用内存,提高响应速度。
实体内部的安全并发执行。对于作用在同一个实体,但是依赖的组件不重合的系统,可以“绝对安全”的并发执行。
清晰可控的数据依赖关系。对系统依赖数据的强制声明要求,没有声明则不可用,杜绝不可知的隐秘关联。
更加便利的代码复用机制。系统只依赖于组件,亦即不同类型的实体只要拥有相同类型的组件,就可以直接适用同一个系统。

ECS概念同步

ECS(Entity-Component-System)是一种软件架构模式,主要用于游戏开发中。ECS包括 由数据组件(Component) 组成的实体(Entity),以及在组件上运行的系统(System)。

实体:一个实体代表一个通用对象,实体是由组件构成。
组件:组件用于保存实现某方面功能所需的数据。通过不同的组件让实体拥有不同的功能。
系统:系统是一个过程,它作用于具有所需组件的所有实体。

简而言之,所有的数据都以组件的形式存在;实体是互相关联的组件的聚合体;系统是只作用于拥有其关注的组件的实体的方法。

举个例子,这是一个Player数据模型

Name Level Gold Weapon
大壮 15 50 fist
小美 5 100 /
丧彪 50 0 knife
佛伯乐 80 200 gun

表中每个格子即为一个组件,每行组件构成一个玩家实体。其中玩家“小美”,没有Weapon组件,只由三个组件构成,其他的“大壮”,“丧彪”,“佛伯乐”都由四个组件构成。

假设存在一个“收你5块钱,给你的武器加一个buff”的系统,显然这个系统依赖 Gold组件和 Weapon组件。那这个系统只可以作用于玩家“大壮”,玩家“丧彪”,玩家“佛伯乐” 。而不能作用于玩家“小美”,因为小美没有Weapon组件。

[转]ECS的说明和在游戏后端的应用

只有三个组件的 “小美 ”

“典型后端”遇到的“典型问题”

了解什么是ECS后,还需要了解什么是游戏后端。这里以笔者对一些游戏项目的了解(道听途说),给出一类“典型游戏后端”的描述:

将游戏数据抽象为玩家,战队,军团等不同类型的Actor,并使用不同的服务来处理对针对不同类型Actor的操作。
为了保证实时交互效率,在内存中缓存大量的游戏数据,核心业务很多都是有状态服务。
数据管理以Actor为核心,挂载各种Mgr来管理Actor的数据。
整个Actor都暴露给业务开发。同一个Actor的不同Mgr可以通过Actor来任意的互相访问。
线程调度以Actor为单位,保证同一个Actor只有一个工作线程在运行。
消息分发针对Actor进行,框架层保证将消息传递给对应的Actor,之后由业务层来分发给具体的业务逻辑来处理。

[转]ECS的说明和在游戏后端的应用

一个“ 典型后端 ”

显然,这类后端架构以Actor为界,Actor以外由框架层负责,Actor以内由业务层负责,大部分的业务逻辑都以Actor为核心。当收到一个外部请求后,框架层会根据请求检索Actor。如果检索到对应Actor,则转发请求,由Actor附带的业务层代码来处理业务逻辑。

根据这些特点,就能预测到经过长期的开发工作后,这类架构将面临的“典型问题”。

将整个Actor暴露给业务层,可以让业务开发更加的便利。但是长期的代码腐化,必然会造成Actor内部逻辑强耦合,各模块交叉依赖,难以梳理。任何对老模块的修改和引用都会变成一场难以预料的冒险。
框架层对Actor的状态感知只有可用(完全加载)和不可用(未加载 或 部分加载)。即只能等Actor的完全加载后才能提供服务,如果出现数据加载瓶颈(比如服务迁移场景)或者部分数据源异常,会影响到整个Actor的响应速度。
随着游戏玩法的逐渐丰富,日渐堆砌起的巨型Actor肆意侵占着宝贵的内存资源,造成一种“明明玩家越来越少,但每个玩家的服务器成本却越来越高”的情景。
一个Actor只能有一个工作线程在运行。但是对于像战队,军团等与玩家呈一对多关系的Actor会存在有并发请求的场景,串行执行的模式,可能会带来不可忽视的延迟问题,而且也浪费CPU资源。

造成这些问题的主要原因就是 框架层所提供的针对Actor级别的治理能力,面对逻辑和数据都日益膨胀的Actor本身,显得捉襟见肘。而以Actor为核心的业务开发模式,又反向限制了框架层向更深层治理能力发展的可能。

要解决这些问题有很多办法。本文选择的出路是对开发架构重构,将Actor移入到框架层,业务层抛开Actor只专注于逻辑和与逻辑直接关联的数据。

如果能重来…

如前述,ECS模型由实体,组件,系统构成。如果将实体视为Actor,将组件视为业务数据,将系统视为业务逻辑。那完全可以用ECS模型来重构前述开发架构:

使用组件来承载所有的数据,组件之间根据承载的功能分割数据。相同类型的组件集中管理。
一组相互关联的组件组成一个逻辑上的实体。实体退化成一个抽象概念,类似“Key”,拥有相同“Key”的组件在逻辑上组成一个实体。
业务逻辑以系统的方式实现,系统是一个以组件作为参数的纯方法。系统本身不存储任何数据,是可重入可并发的。系统以组件做为参数,通过修改一个或多个组件的内容,来实现业务逻辑。
系统需要显式的声明其依赖的组件类型,且只能感知(读取&修改)其依赖的组件。同一类实体,可能拥有不同的组件(例如到达XX等级,才能解锁XX系统)。因此系统需要根据业务逻辑,显式的声明其依赖的组件,只有同时拥有这些组件的实体才能(将系统依赖的组件)作为参数传入。同样的,系统在表达业务逻辑时,也只能读取和修改其依赖的这些组件。

[转]ECS的说明和在游戏后端的应用

经过ECS模型重组的后端

在ECS模型中,业务层通过系统,组件,以及系统对组件依赖关系 来实现。而实体则退化为用于表示组件间关联关系的抽象概念。丧失了逻辑功能的实体,可以很容易的被吸收进框架层。

在ECS模型中,一次外部请求的流程可以按以下流程进行:

1、框架层接受外部请求 <系统名, 实体Key>

2、框架层检索到对应系统,获得依赖组件列表(业务层显式定义)

3、框架层检索本地组件池,查找匹配实体Key的组件列表

4、框架层对于缺少的组件,将实体Key传入对应组件的加载接口(业务层实现),加载数据。

5、如果获取到满足系统依赖的组件,则将这些组件传入系统的业务层接口(业务层实现)。

6、系统的业务层接口通过修改组件内容,实现具体的业务逻辑。

如下是几种构思的请求链路

[转]ECS的说明和在游戏后端的应用

几种常见的请求链路

辅助说明的样例

为了便于理解,给出一个样例。样例只是用于说明设计思想,重在理解。

如下是一个包含两种组件 < 武器组件,钱包组件 > 和 两个系统 < 激活武器系统,查看武器列表系统 > 的服务。业务层只需要实现组件和系统接口即可完成业务逻辑开发。该样例可实现:

针对外部请求按需加载内存
针对同一个实体可以并发运行多个互不冲突的系统(依赖组件列表不重合)

//component.go
// 组件,包含一个武器组件 (激活武器,获取武器列表) 和 一个钱包组件(消耗金币)
package ecs

// 组件接口
type Component interface {
	Create(Key)	error // 创建组件,并加载数据
	Clear() // 清空组件内存
}

// 武器组件
type WeaponComponent struct {
	WeaponList []Weapon
}

func (this *WeaponComponent)Create(Key) error{
	// 假装我有从DB加载数据
}

func (this *WeaponComponent) Clear() {
	// 假装我有清理内存
}

// 激活武器,并花5金币
func (this *WeaponComponent) ActiveWeapon(weaponid int, bag BagComponent) error{
	bag.UseGold(5)
	this.WeaponList = append(this.WeaponList, weaponid)
	return nil
}

// 获取武器列表
func (this *WeaponComponent) GetWeaponList() []Weapon {
	reuturn this.Weapon
}

type BagComponent struct {
	Gold int
}

func (this *BagComponent)Create(Key) error{
	// 假装我有从DB加载数据
}

func (this *BagComponent) Clear() {
	// 假装我有清理内存
}

func (this *BagComponent) UseGold(num int) {
	this.Gold -= num
}

 

//entity.go
// 定义Key 和 组件编号,便于使用

type Key int

// 定义一下编号
type Component_Code
const ( 
	WeaponCop_Code Component_Code = 1
	BagCop_Code Component_Code = 2
)

// 编号和组件关联一下
func CreateCop(code Component_Code) Cop {
	if code == WeaponCop_Code {
		return &WeaponComponent{}
	} 
	if code == BagCop_Code {
		return &BagComponent{}
	} 
	return nil
}

 

// sys.go
// 定义两个系统,激活武器系统,获取武器详情系统

// 系统接口
type Sys interface {
	RouteMatch(msg Msg) bool
	GetCopList()[]int
	FuncMain(msg Msg , Cop...)
}

// 激活武器系统
type ActiveWeaponSys struct {
	
}

func (this*ActiveWeaponSys) RouteMatch(msg Msg) bool {
	if msg.Sysname = "ActiveWeaponSys" {
		return true
	}
	return false
}

// 依赖 武器组件和钱包组件
func (this*ActiveWeaponSys)GetCopList()[]int {
	return []int{WeaponCop_Code, BagCop_Code}
}

// 同时依赖多个组件
func (this*ActiveWeaponSys) FuncMain(msg Msg, weaponcop, bagcop)  {
	weaponcop.ActiveWeapon(msg.Weaponid, bagcop)
}

// 获取武器信息系统
type GetWeaponSys struct {
	
}

func (this*GetWeaponSys) RouteMatch(msg Msg) bool {
	if msg.Sysname = "GetWeaponSys" {
		return true
	}
	return false
}

// 依赖 武器组件
func (this*GetWeaponSys)GetCopList()[]int {
	return []int{WeaponCop_Code}
}

// 只依赖一个组件
func (this*GetWeaponSys) FuncMain(msg Msg, weaponcop) {
	return weaponcop.GetWeaponList()
}

 

// main.go
// 框架驱动
type WorldEngine struct {
	SysPool []System
	CopPool []Component
}

func main(){
	//
	engine := WorldEngine{}
	engine.SysPool = append(engine.SysPool, ActiveWeaponSys)
	engine.SysPool = append(engine.SysPool, GetWeaponSys)
	// 请求 激活武器
	for Msg <- {"ActiveWeapon","大壮"} {
		// 检索满足条件的Sys
		sys = MatchSys(engine.SysPool, Msg."ActiveWeapon")
		// 获取依赖的组件列表
		CopList := sys.GetCopList()
		// 检索匹配到“大壮”的组件是否满足Sys要求
		WeakCopCodeList = CheckCop(CopList, engine.CopPool, Msg."大壮")
		// 调用Cop加载接口,加载缺少的Cop
		for CopCode in range WeakCopList {
			CopPool = append(CopPool, CreateCop(CopCode, Msg."大壮"))
		}
		go func() {
			Sys.FuncMain(Msg, CopList)
		}

	}
}

 

一个可以Run的Demo

这是对上述样例的实践Demo,考虑到在实践中会存在一些和实体本身相关的逻辑,所以保留了实体(Entity)用作触发器和组件(Component)的索引。

gameserver-ecs/README.md at main · Tudongye/gameserver-ecs

[转]ECS的说明和在游戏后端的应用

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...