文章目录
前言
近期看了守望先锋团队关于在2017GDC发布的守望先锋的ECS开发框架和帧同步的演讲,对ECS实现的帧同步架构十分着迷。因此打算着手实现一个ECS下的帧同步的游戏Demo。
关于ECS开发,由于其与传统的Unity的OOP开发方式不同,因此理解起来颇为抽象,在GitHub上找到了一个比较好的ECS的项目:UnityLockStepDemo 。在学习了几天源码之后稍微对ECS有了一定的理解。不过这个项目的架构不是很完善,有些地方看的还是很迷糊,特别是在Unity的游戏场景开发上十分不理解(其实现方式中,每个GameObjcet的实例化都是通过加载预制体,然后在服务端进行物理计算,导致客户端和服务端同一个功能模块的代码不尽相同,看的很迷糊)。因此放弃了在该项目框架的基础上开发。
什么是ECS
关于这个我想去看看我刚才说的守望先锋的ECS开发框架和帧同步的演讲视频就明白了
ECS全称Entity-Component-System,即实体-组件-系统。是一种软件架构模式,主要用于游戏开发。传统的Unity开发是OOP(面向对象),我们通常会在场景中创建游戏物体,挂上MonoBehaviour的脚本进行交互。例如一个游戏主角,我们会给他控制器脚本来控制它的移动,用属性脚本来定义它的属性,通常为了避免逻辑类和数据类的耦合,我们会采用MVC架构,将数据和视图分离,用控制器来控制二者的交互。
而数据和逻辑分离正是ECS中的一个重要思想,ECS 遵循组合优于继承的原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。
实体与组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件。通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
换句话说,每个实体不是由“类型”定义的,而是由与其关联的组件定义的。组件如何与实体相关连,取决于所使用的ECS框架如何设计。
ECS的设计
玩家、怪物、小动物、建筑,不再有继承关系,它们按需Set自己需要的组件
玩家:视野组件、背包组件、装备组件、战斗组件
怪物:视野组件、战斗组件、AI组件
小动物:视野组件、AI组件
建筑:视野组件、战斗组件、AI组件
所有的功能都由组件提供和控制,对象本身之间再也不受继承的限制。对象与组件是一个一对多的关系,对象拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。
组合的优势显而易见,而ECS再此之上还多了一个"系统"的概念。ECS规定组件只保存状态(即数据),具体的行为由系统控制。例如处理移动的系统仅仅关心拥有移动能力的对象,它会遍历所有拥有MoveComponent组件的对象,并根据相关的数据(速度、位置、朝向等),更新对象的位置。
在ECS架构中,组件只保存状态而不具有行为,实体中挂载着组件,最后由系统负责获取相关组件完成对应运算,其核心思想就是对数据与运算的分离。系统只有方法,组件只有成员变量。
系统完全不知道每个实体是什么样的,只关心它的一小部分的组件,然后对这一小部分组件,执行一系列共同的行为。有些实体可能会有30个组件,有些只有2-3个,但是系统不关心这些,只关心它们的行为所需要的那些组件子集。每一个系统在运行的时候,即不知道也不关心这些实体是什么,只是基于与实体相关的一个组件子集来运行。
守望先锋的 实现 基本上看起来如下图所示。
- 游戏世界,称为EntityAdmin,它保存了一组系统,以及一个以实体ID作为键值的实体哈希表。
- Entity(实体) 保存了实体的ID,一个组件列表,以及可选择的对游戏资源的引用(称之为实体的定义)。
- Component(组件) 是一个有着上百个子类的简单的基类,每个子类组件都有自己的成员变量,系统利用这些变量来表现行为。Component中的多态函数仅用于实体的生命周期管理,我们会重写它的创建函数和析构函数。其它添加到组件实例(Component的子类)中的函数就只有一些辅助函数,用于更方便的访问其内部状态,但它们没有真正的行为,只是简单的访问函数(因为行为由系统控制)。
EntityAdmin会调用每个系统的更新函数(每帧的更新),每个系统都会做一些事情:
ECS的实现
这是一个玩家联网系统。他负责处理所有游戏服务器上的挂机行为,这个系统会遍历所有的联网组件,联网组件是在服务器上对应每个玩家连接的组件,它在一个代表玩家的实体当中。联网组件会有一个输入流和状态,会读取玩家的输入流,确保你在做某一些事情比如按下了一个按键,读取状态是为了确保你通过某种方式对游戏做出了影响。只要有以上的行为,该系统就会把你的挂机时间清零,否则就会通过保存在联网组件里的联网引用,对你发出一个让你行动的警告信息。
为了让这个行为能够运行,这个系统需要处理的实体,必须拥有完整的元组。比如一个AI机器人,它会有状态组件,但是它没有联网组件和输入流组件,所以它不受这个行为(挂机检测)的约束。我们没有必要因为挂机而把AI踢出去。(卧槽,天才,这个方案太完美了,而且输入组件的未响应通过System来返回给联网组件进行事件处理(输入组件长时间未操作,则通知联网组件告诉服务器该客户端挂机了,而不是传统的心跳包那样单纯由联网组件来判别是否挂机。)
我们为什么不采用传统的OOP编程,使用组件模式来做这件事呢?让联网组件重写Update函数,来对挂机行为进行跟踪。
因为联网组件实现了多种行为,它不光涉及到挂机检测,还涉及到互相联网的玩家之间的网络消息广播,它保存了你用于确定玩家姓名的状态,它保存了玩家的持久化记录(比如他们所解锁的成就)。所以到底哪种行为需要放到组件的Update函数里呢?那其它的行为又放到哪里呢?
(是的,感同身受,这正是我在编程设计中经常产生的疑问,例如一个View的Update事件我是放在View本身还是放在它的Controller当中)
在传统的OOP中,一个类既包含行为(函数)又包含状态(属性),但是联网组件并没有行为,只有状态。从OOP的角度来讲,联网组件不是一个对象,它在不同的时间,对于不同的系统来说,是不同的东西。也就是说这个状态是灵活可变的,而变数在于World需要怎样使用System去调度,而每个System不会关心一个Component中与它们无关的部分,也就是所谓一千个人心中有一千个哈姆雷特。在联网系统看来联网组件是挂机剔除用的目标,而在TCP系统看来联网组件是它要广播的目标,在游戏客户端看来,联网组件是它获取数据的目标。而非传统的一个对象应该去实现以上所有的事情。(看起来和OOP中定义一个类然后Public对应方法出去给其他类调用一样,但这是一个思路的转变,实现思路由原来联网类设计时需要实现这么多功能给其他类调用变成了每个部分System自行定义获取组件后的策略,这样同个组件相对于一个系统而言就是单一功能的)
ECS中的单例组件
在OOP中,当我们需要处理一个全局的输入时,通常我们会获取然后并将其保存在一个静态类中,然后让其他的系统来访问静态类中的变量。
现在使用ECS中有一个问题,如果多个World需要使用静态类,它们先后访问其中的变量,在不同输入下获取了不同变量值怎么办?
(此处对应原文的举例是守望先锋的死亡回放系统,死亡回放的镜头是本地客户端中进行的,实际上是从主游戏的world切换到一个回放世界的world然后获取服务器发送的回放数据包把玩家几秒前的操作再进行一遍。这样的问题在于如果输入类是全局定义的,那么在回放时全局输入变量自然发生改变,再切回主游戏世界,那么此时输入有问题就会导致操作异常)
守望团队的解决思路是,将这个状态放在一个单例组件中,所有需要访问这个状态的操作都需要通过单例组件来访问(此单例指的是在每个世界中有且只能有一个该组件,像守望先锋说的上述情况,两个世界就有两个输入组件实例,但是在每个世界中是唯一的)。由单例组件使用命令模式进行变量值传递,这样所有对单例组件的变量获取都能根据当前输入获取正确的变量。(这正是Unity的New InputSystem的实现思路,所有对输入的获取都通过实例化的InputSystem得到。就能够使用多个实例来保存不同的输入数据了。)
教训就是不要使用全局组件,这会导致不同世界的耦合,为每个世界设置一个单例组件
共享行为-Utility函数
现在讨论另外一个问题,与共享行为(sharedbehavior)有关。
有时,同一个主体的两个观察者,会对同一个行为感兴趣。回到前面樱花树的例子,你的小区业委会主席和园丁,可能都想知道这棵树会在春天到来的时候,掉落多少叶子。
根据这个输出可以做不同的处理,至少主席可能会冲你大喊大叫,园丁会老老实实回去干活,但是这里的行为是相同的。
举个例子,大量代码都会关心“敌对关系”,例如,实体A与实体B互相敌对吗?敌对关系是由3个可选组件共同决定的:filter bits,pet master和pet。filter bits存储队伍编号(team index);pet master存储了它所拥有全部pet的唯一键;pet一般用于像托比昂的炮台之类。
如果2个实体都没有filter bits,那么它们就不是敌对的。所以对于两扇门来说,它们就不是敌对的,因为它们的filter bits组件没有队伍编号。
如果它们(译注:2个实体)都在同一个队伍,那自然就不是敌对的,这很容易理解。
如果它们分别属于永远敌对的2个队伍,它们会同时检查自己身上和对方身上的pet master组件,确保每个pet都和对方是敌对关系。这也解决了一个问题:如果你跟每个人都是敌对的,那么当你建造一个炮台时,炮台会立马攻击你(这里解释一下我的想法,如果单纯比较两个位的值是否相同的话是不会出现这种情况的,我认为之所以出现这种情况,是上文的filter bits中,猜想他们处理同队的方式是掩码计算而非判断。有一个掩码被设置为了与所有其他势力敌对,这样就无需与所有其他实体的掩码进行判断,直接攻击就好了。但是宠物和主人都设置了与所有势力敌对,那么它们两本身也是相互敌对的,为了避免这个情况,引入了宠物和主人组件,用来检查双方是否为友军的关系)。
如果你想检查一枚飞行中的炮弹的敌对关系,只需要回溯检查射出这枚炮弹的开火者就行了,很简单。
这个例子的实现,其实就是个函数调用,函数名是CombatUtilityIsHostile,它接受2个实体作为参数,并返回true或者false来代表它们是否敌对。无数System都调用了这个函数。
这个辅助函数只用到了3个组件,少得可怜,而且这3个组件对它们都是只读的。更重要的是,它们是纯数据,而且这些System绝不会修改里面的数据,仅仅是读。
我们把这类跨组件调用的函数称为Utility函数
如果你想在多处调用一个Utility函数,那么这个函数就应该依赖很少的组件,而且不应该带副作用或者很少的副作用。如果你的Utility函数依赖很多组件,那就试着限制调用点的数量。也就是说尽量少用,如果非得要用,那么它依赖的组件也尽量要少
还有一个例子是CharacterMoveUtil,这个函数用来在游戏模拟过程中的每个tick里移动玩家位置。有两处调用点,一处是在服务器上模拟执行玩家的输入命令,另一处是在客户端上预测玩家的输入。
单点调用
像刚才举例的辅助函数,因为在ECS中是跨结构调用的,所以我们要特别注意它的使用时机,尽量少调用甚至是单点调用。
视频中以特效生成举例,由于特效生成是生成实体,所以较难管理。一旦辅助函数产生修改,很多调用的地方都可能出现问题,思路是把所有需要生成特效的函数延后执行,将特效存储在一个数据结构中,最后统一放在一个地方(例如渲染帧调用渲染事件时)进行单点调用。这样若出现问题的话只会在每帧渲染处理时出现问题了。而且好处在于我们还可以进行一些其他的渲染处理来提升性能。(例如12个DVA一起射击产生特效,我们不用同时生成,而是分批次的延迟生成就能减少性能消耗)
帧同步
相同的输入 + 相同的时机 = 相同的显示
帧同步的处理包含两部分,一部分是逻辑帧,一部分是渲染帧,逻辑帧先处理,用于同步游戏中的数据状态,随后才进行游戏的渲染(对游戏的更新)。
帧同步的实现有几个技术点:
- 定点数(包括Unity内置变量需要修改成定点数,包括随机数对于每个客户端都使用服务端指定的随机数种子)
- 单线程,避免多线程运行导致的不同步
- 服务器定时返回包给客户端,本地存储两个命令Buffer,一个保存发送自己本地的Buffer(计算自己的输入和预测其他玩家的输入),一个保存服务器发回的Buffer,用于比较两边的帧队列。
举个例子,如果因为服务器自身原因卡顿了,本地已经跑到了第7帧,而在此之前服务器并没有发回包体,我们就按照本地的预测帧处理。现在网络恢复了,此时服务器发回第2帧,逐帧匹配服务器的包,发现第2帧发送的数据与本地预测不匹配,则应当回到第1帧执行完毕时的状态(回滚)。若服务器又因为延迟丢包,则依旧需要预测本地处理,重复上述过程直至同步。
另一种情况是服务器正常发包,由于某些原因客户端接收的包丢包了,目前已经执行到了第7帧,客户端校验后发现自己的第2帧和服务器的第2帧是对不上的,此时就要回滚到第1帧结束,再以服务器的第2帧为同步,并且由于目前已经是第7帧了,还需要客户端加速游戏时间,使得客户端能快速回到第7帧。
还有一种情况是客户端发包的时候丢包了,那么此时服务器发现没有接收到第2帧的数据,此时服务器总得返回个什么,它会告诉客户端你发的包丢了,然后返回一些预测帧的内容给客户端(服务器也可预测帧的),客户端接受服务端的预测帧,然后加速发包的速率,用以弥补之前丢掉的包。因为接收到的包更多了,所以同时服务端扩大buffer区用于传回包,当客户端回复后又慢慢回到原来状态。
- 跨平台,通过客户端调用接口来实现,同一个接口可以对应不同平台客户端的操作
- 多实例,如果客户端内实例A和实例B都用了同一个静态变量,如果服务器端对静态变量改变了那可能产生AB对静态变量读取的不同。因此静态变量应该改为Const,并且在运行时不能随便改变。单例模式也是同理,所以需要同步的部分谨慎使用单例模式。
- 帧同步输出的日志包含每帧的帧状态和帧执行,方便溯源同步中的Bug。为了避免预测回滚时重复存储同一帧的日志,因此使用字典来存储每帧的数据,新日志将覆写旧日志
- 确定性,假定输出流是稳定的,那么客户端总是会超前于服务器的,超前了大概半个RTT加上一个缓存帧的时长(RTT就是TCP中一个消息来回的时间,也就是Ping值)
首先同步CS端时间,然后固定帧的发送周期,通常每16ms发送一次。在本地模拟的预测帧被守望先锋官方称为提前量,提前量执行时间=(RTT/2)+1(这里的1是1帧,守望的资料给的是16毫秒),也就是1/2RTT时间+1帧后执行本地预测帧。
8.UDP的可靠传输,帧同步发包一定要快,所以使用UDP,而UDP是没有可靠传输的,可能出现丢包的情况,所以需要实现UDP的可靠传输(这个Github倒是有很多已经实现的了)
9.作弊校验,由于帧同步只同步一些指令,所有的逻辑计算都放在了客户端,因此作弊是相当容易的,作弊校验是很重要的。而状态同步的计算逻辑是在服务端,因此玩家很难作弊。