[转]ECS在游戏后端开发的应用

news/2024/10/1 23:49:16

 

 

 

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

 

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

结论前置

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

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

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

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

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

ECS概念同步

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

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

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

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

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

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

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

只有三个组件的 “小美 ”

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

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

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

显然,这类后端架构以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模型中,一次外部请求的流程可以按以下流程进行:

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

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

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

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

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

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

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

几种常见的请求链路

辅助说明的样例

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

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

  1. 针对外部请求按需加载内存
  2. 针对同一个实体可以并发运行多个互不冲突的系统(依赖组件列表不重合)
//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 = 1BagCop_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) boolGetCopList()[]intFuncMain(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 []SystemCopPool []Component
}func main(){//engine := WorldEngine{}engine.SysPool = append(engine.SysPool, ActiveWeaponSys)engine.SysPool = append(engine.SysPool, GetWeaponSys)// 请求 激活武器for Msg <- {"ActiveWeapon","大壮"} {// 检索满足条件的Syssys = MatchSys(engine.SysPool, Msg."ActiveWeapon")// 获取依赖的组件列表CopList := sys.GetCopList()// 检索匹配到“大壮”的组件是否满足Sys要求WeakCopCodeList = CheckCop(CopList, engine.CopPool, Msg."大壮")// 调用Cop加载接口,加载缺少的Copfor 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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.ryyt.cn/news/44925.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈,一经查实,立即删除!

相关文章

由心知天气服务器响应的实时天气数据并进行JSON解析

由心知天气服务器响应的实时天气数据并进行JSON解析 #include <netinet/in.h> #include <arpa/inet.h> #include <stdio.h> #include <errno.h> #include <sys/socket.h> #include <netinet/in.h> #include <netinet/ip.h> #include…

2024.6.17鲜花/错误的号码

XY 星的星际新闻报一直不太畅销,所以报纸上会有一些广告,毕竟星际新闻局的非机器人员工也得吃饭。 有一则广告是这样的:【数据删除】研学基地位于【数据删除】,该研学基地致力于让学生体验一个幻想纪前的生活并培养学生不借助现代高科技的群居生活能力。该研学基地将于幻想…

红日靶场3

环境搭建 拿到靶场有5台机子,配置网段,仅主机模式网段vmnet2网段为192.168.93.0即可,出网网卡设置为桥接即可,点击继续运行即可 注意的是web机的两台linux开启后记得拍快照,web机隔一段时间web服务会出问题 web渗透 主机发现(我的桥接网段是192.168.1.0) namp -sP 192.168…

2024/6/9

今天写数据库的实验五,使用Java写了一个十分简易的数据库,连输入都没有,只是证明我用Java连上了sqlserver,代码如下:import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLExc…

2024-06-17-Spring 源码阅读(三)Bean 的生命周期

由于 Spring 源码非常多,博客中贴源码会占用大量篇幅,阅读困难。详细分析部分会以 commit 提交形式关联源码提交,画图例来说明源码整体逻辑。 Bean 生命周期主体逻辑 相关代码:Bean的基本创建流程、lazyInit、循环依赖 Bean 对象创建基本流程 通过最开始的关键时机点分析,…

C# TEKLA 二次开发 版本兼容性解决方案

制作的exe程序,就存在版本兼容性问题 用2022 api编译的exe在2024 中无法启动 解决方案 将exe放在如下位置从此处启动exe即可从宏中可以获取string XSDATADIR = ""; TeklaStructuresSettings.GetAdvancedOption("XSDATADIR", ref XSDATADIR);string extens…

欢迎 Stable Diffusion 3 加入 Diffusers

作为 Stability AI 的 Stable Diffusion 家族最新的模型,Stable Diffusion 3 (SD3) 现已登陆 Hugging Face Hub,并且可用在 🧨 Diffusers 中使用了。 当前放出的模型版本是 Stable Diffusion 3 Medium,有二十亿 (2B) 的参数量。 针对当前发布版本,我们提供了:Hub 上可供下…