前言

最近开坑了一个状态帧同步战斗系统视频教程,总共有5个视频

其中第一个和最后一个都好说,我自己乱吹就可以了,但是中间三个重量级比较难处理,我的计划是实时录制原GDC视频 + 分享自己的理解和思路,原视频的平均时长都在一个小时左右,如果直接莽上去很有可能在讲解的时候思路断掉,所以我需要针对他们梳理出一个大纲来理清楚讲解思路,顺带方便大家自己在学习守望先锋GDC视频的时候有针对性的去了解自己需要了解的部分。

《守望先锋》架构设计和网络同步

原视频链接

视频内容大纲

00 : 00 - 1 : 00 :守望先锋游戏与开发团队介绍

1 : 00 - 7 : 15:ECS概念介绍与守望先锋中使用ECS的示例

7 : 15 - 8 : 07:ECS VS OOP,核心问题还是庞大工程中引用和解耦问题。传统OOP中,抛开常知的继承炼狱问题不谈,一个类既是数据又是逻辑,那么当一个类会与多个不同的类进行交互的时候,就会面对如何拆解数据和逻辑提供给相对应的类的问题,如果选择不拆,那么就需要面对越来越庞大的类,如果拆了,那么一些只有其他类才需要用到的数据和逻辑就应当被其他类在Update或者其他函数中调用,这样就会面对头疼的耦合问题。而在ECS中,通过把数据拆分成Component,并利用System和Utilities函数就可以尽可能的松散化这种耦合,因为一个System/Utitlites就是一个副作用很小的纯函数(这其实也是ECS的一大魅力),他会筛选出对自己关心的数据然后进行操作,后续开发迭代起来针对System/Utitlites即可。另外就是概念冲突问题,因为有些类其实并不是个对象,就只是个状态数据集合,会被很多其他的类所使用,并且在不同的类里,不同的时机,有不同的意义和抽象,那么这个数据类相关的操作放在哪里呢?放在它本身那里?似乎不太妥当,因为随着代码迭代没有办法预见有多少类会使用它,那就每个使用它的类专门为这个数据类维护调用逻辑?这似乎是正确做法,但随着开发迭代越来越多的代码填充进类里,函数里(也就是耦合越来越严重,脑力成本越来越高的时候),很容易让这个调用函数变得意义不明,而在ECS中,这个数据类的意义与抽象,完全就取决于System和Utitlites,而不是它本身,简洁明了。

8 : 07 - 11 : 10:数据逻辑分离的优势以及守望先锋所面临的问题,主要是跨System的调用导致的职责模糊,以及C++头文件引用导致的编译时长问题

11 : 10 - 15 : 08:死亡回放系统带来的重构-Singleton组件,其实在此之前演讲人所说的单例是真正意义上的单例(全局唯一),但是在遇到死亡回放需求时就显得力不从心了,因为死亡回放的World和正常游戏的World完全是两个不同的World,如果共用一个全局的单例很容易造成状态上的错误。所以他们选择制作一些“单例”组件,值得注意的是,这里的单例组件是相对于每个World而言的,而不是全局唯一的,这样就确保了不同的World数据完全不会互相影响。

15 : 08 - 17 : 39:System之间的共享行为-Utilities函数,需要注意的是,Utilities函数的本身不应当牵扯到过多的组件。它的调用也应当是简单的,少量的,因为调用的地方越多,越复杂,对于整个项目来说副作用就会越来越大。

17 : 39 - 22 : 22:Utilities的优化与示例

22 : 22 - 33 : 07:守望先锋网络同步中的预测,回滚,FEC,网络波动的处理,以及一些细节的讲解与演示,需要注意的是,守望先锋的预测回滚是只针对本地玩家的,比如我在操作英雄A,另一个人在操作英雄B,那么我就只会预测英雄A,而英雄B的行为表现则完全依赖于服务器的回包。实际上在这部分视频里演讲者并没有过多的提及ECS的优势,在我的实践过程中也感觉到ECS在这部分里只是起到了比较方便的访问与操作整个项目中其他的组件(松耦合),分离数据和逻辑的作用,让预测和回滚功能开发迭代起来更加顺畅而已。

33 : 07 - 34 : 54:基于StateScript的技能系统在网络同步中的预测,回滚演示,其实使用基于事件驱动的行为树可以完美还原视频中所演示的功能,事实上NKGMoba也正是这么做的

34 : 54 - 39 : 16:命中判定的预测和确认,客户端只会做命中预测,而不是伤害的计算(因为有的客户端是屑,我们选择不相信它,所以根本不给他预测的机会),在这部分演讲者提及的“倒回”概念其实只是为了更加方便观众理解,因为客户端是先行于服务端的,所以本地玩家位置信息会和服务器玩家不一致,然后服务器其实是会以本地玩家在服务器上的位置进行击中判定,也就是“倒回”。之后的命中框演示意思是预测回滚这一行为的优化,因为很多时候的射击范围比较有限(就是根据本地玩家的准心与Entities过去几帧的范围集合是否有相交来判定),在这个范围外的Entity根本没有机会会被击中,所以就只选择预测这个射击范围内的Entities,这样不用预测回滚整个世界,节省了相当大的性能空间。最后的那个击中判定演示视频就是以服务器为准的判定演示。

39 : 16 - 40 : 40:以一个在不同Ping值下射击死神的视频,证明了高Ping值下再进行预测是完全没有必要的,因为几乎所有的预测结果都是错误的。

40 : 40 - 41 : 33:另一个命中预测失败的示例,这是因为玩家射击时本地状态由于网络延迟的问题和服务器上本地状态是不一致导致的,比如你开火时认为自己还站立在地面上,实际上服务器在这一帧进行判定时,自己已经在空中了,那么以原本射击角度发射的子弹肯定就打空了。

41 : 33 - 42 : 19:最后是一个150ping下,射击开启无敌技能的死神视频演示,在本地玩家的视角中,子弹命中时死神还没有开启无敌技能,应当有伤害,但是在服务器上进行这一帧判定时,死神其实是已经开启无敌技能的,所以本地玩家命中判定失败了。

42 : 19 - 结束:对ECS进行的总结,概括了ECS开发过程中需要遵循的一些准则,多次强调ECS是一个项目中各个组件模块的粘合剂,并且举了一个寻路系统的示例来说明不适用ECS的模块就不要用ECS,否则会适得其反。实际上第三幕讲到的脚本系统,技能系统和武器系统核心基本上就是基于OOP进行开发的,然后通过ECS与项目中其他组件进行交互。其实我个人想说的也是,OOP和ECS并非水火不容,一味的推崇ECS和一味的摒弃OOP都是不对的,他们都各自有自己的使用领域,像技能系统,Buff系统这种非常强内聚的系统使用OOP来开发再合适不过,因为在这些系统里的对象生命周期都比较特殊,是用ECS做起来会绕一大圈,而是用OOP则可以直接通过虚函数完成这些生命周期的开发,非常方便,具体到细节,比如一个Buff的效果是影响Entity的位置,那么就可以借助于ECS的解耦简单直接的获取到MoveComponent进行位置的修改,这正是OOP和ECS结合的大成之作。

《守望先锋》回放技术-阵亡镜头、全场最佳和亮眼表现

原视频链接

00 : 00 - 06 : 21:简单介绍了死亡回放需求的重要性,并且举了几个例子来演示

06 : 21 - 14 : 31:网络同步模型概览,详细介绍了由 Glenn Fiedler概括的三种主流网络同步模型 ,确定性帧同步(deterministic lockstep),常用于竞速游戏或者星际争霸2、魔兽争霸2这样的即时战略游戏;快照插值(snapshot interpolation),FPS经典同步模型,《Quake》最早采用,后续众多FPS在之上做了改进;状态同步(state synchronization),有点像是快照插值的改进版,也是Overwatch所采用的。其实到这里我觉得我有必要规定一下本系列教程里提及的帧同步,状态同步,状态帧同步具体是什么意思了,其实很简单,帧同步就是按帧同步数据,状态同步就是数据发生变化时同步,两者结合就是状态帧同步——在数据发生变化的帧同步数据,和是不是客户端本地运算,是否使用定点数,是否服务器权威没有任何关系。在这个守望先锋的分享里提及的状态同步我们默认为我们所说的状态帧同步。

14 : 31 - 20 : 05:一些专业名词的释义,有一个状态同步整体流程讲解的具体示例

20 : 05 - 27 : 31:回放系统的设计理念与一些细节,比如全量快照,脏数据的生成与处理,其实把每帧的全量快照+脏数据合并起来就是我们回放系统所需要用的数据。并且展示了一些回放系统相关API,最后演讲者提出回放系统的设计没有基于Component,而是基于Entity,基于Component固然很美好,每个System搜集所有对应Component,整理脏数据,序列化,发送,但是带宽受限的情况下,一个System的这些行为可能会影响到其余Component的脏数据发送,也就是缺少一个可以统筹全局的调用者,但既然都基于Component了,还有办法再造出一个统治者吗?是不可以的,因为逻辑完全分散在各个System中了,几乎没可能搜集完所有需要的数据,所以选择了基于Entity这一很不ECS(很OOP)的做法,简单直接有效!

27 : 31 - 43 : 23:上一部分的内容主要是在讲述回放系统的设计,这部分则是回放系统开发实践过程中遇到的一些挑战以及相应的处理方案,包含了很多视频示例,在正式的的游戏环境中遇到的情况远比在脑海中开发回放系统所遇到的问题多得多。

43 : 23 - 结束:未来工作目标

《守望先锋》网络脚本化的武器和技能系统

原视频链接

00 : 00 - 01 : 20:自我介绍,StateScript脚本系统初步介绍,说明演讲的内容有哪些,包括关于Statescript的“为什么” 、“是什么”以及“如何做到的”。为什么我们决定实现这么特殊的一套系统,Statescript到底是什么?以及它背后的技术细节,这部分大约会耗时15分钟。另外会讨论网络通信需求及解决方案,包括脚本系统在Internet环境下遇到的那些限制,以及我们是怎么应对的,约30分钟。然后会分析一下这种方法的好处和挑战,这部分大约5分钟。

01 : 20 - 02 : 34:为什么要开发Statescript?因为原演讲这段话非常精彩,所以我就直接复制粘贴在这里了:我们需要给“非程序员”提供开发上层逻辑的能力,因为我们知道需要创建大量的游戏逻辑,又不希望每个需求都要靠程序员手动编写解决方案。我们希望这个解决方案允许用户“定义”新的游戏状态,而不仅仅是“响应”这些状态。一般典型的游戏脚本系统都有一个相当不透明的游戏模拟过程,其中脚本也能编写逻辑以响应事先定义好的事件,通过用户自己定义变量、函数调用来微调,执行的结果最后都会消失回到黑盒状态。而我们更需要的是一个形式化的、明确的方式,使得脚本开发者(译注:scripter,下面统一用开发者)对状态和状态转移能直接地、完全地掌控。我们想要模块化的代码尽可能多地被复用,我们不会把一个特性(feature,也可译作功能)需求看作是一组功能的堆叠,而是会去设计并实现那些这个特性所需的基础功能组件。我们需要一个无痛的、稳定的方式来实现一个能够通过网络同步的状态机。手写这些代码费时费力而且容易出错,所以,最好让计算机来替你完成这些工作。另外这个方案需要能够与项目引擎的其余部分协同工作,我们也对比了很多第三方脚本引擎,但是最终还是决定自己去开发一套能嵌入到我们的游戏引擎中的脚本语言,以得到最好的结果。

02 : 34 - 08 : 43:Statescript是什么?Statescript是一个节点化的脚本语言,多个节点组成一个节点图,而这个节点图会在运行时根据需求实例化出来附加给目标Entity,比如一个被动技能是一个节点图,一个强大复杂的Buff效果也是一个节点图,当这些节点图附加给Entity时,这个Entity就拥有了这个技能或者被这个Buff所影响了。然后详细介绍了Statescript的几大基本节点,分别为Entry,Condition,Action,State,其中Action可以认为是一个函数调用,而State是一个可能持续多帧的状态节点,比如“等待3s”这个节点就会持续运行3s钟,此外,State其实是一个个包含了很多虚函数生命周期的C++类(再次说明了OOP在这种强内聚模块中强大的开发能力)。然后是Statescript的变量和属性,其中变量分为实例变量和共享变量,前者为当前Statescript独有,后者被这个Entity身上所有Statescript共享,而属性就更加抽象一些,通过C++函数返回一个想要的值,这个函数是可以自定义的,但两者的共性都是返回一个值。随后介绍了下Statescript的Subgraph和Container,前者用于复用现成的节点图,后者将节点划分在不同的区域用作不同的用途,比如将一些节点放置在A容器中,将另一些节点放置在B容器中,那么在A容器的节点只会在客户端执行,在B容器的节点只会在服务端执行。最后介绍了下Statescript的生命周期以及一些程序范式(顺序执行,跳转,循环)

08 : 43 - 10 : 58:一个StateScript示例,玩家按住鼠标右键长达1s后,会进入第三人称视角,人物跃起。随后是一个更加复杂的例子,绿色表示节点被触发,深色表示节点正处于激活状态,透明色表示节点处于未激活状态。

10 : 58 - 17 : 47:Statescript是如何实现的?讲述了Statescript系统的基础架构,并且讲解了State(值得注意的是,State提供了一种监听数据变化的功能,当其监听的数值发生变化时,其OnInternalDependencyChanged将会被调用,其实就相当于事件驱动行为树的黑板条件节点)和Variables(其实就是可以被State监听变化的变量)的工作方式,如果感觉到比较难以理解,可以把Variables想象成行为树中黑板数据,把State想象成一个黑板条件节点,当监听的目标黑板变量变化后满足设定的条件时,将会执行其子节点。最后介绍了一个重要的概念,即结构化数据(structured data,简写为stu)是OW团队自己创造的一套基于C++实现的脚本系统,包含了完善的解析,反射,运行功能,方便了编辑器和运行时模式下使用。最后举了一个Wait State结构化数据的例子:通过Stu编写一个Wait State节点定义,这个Stu可以被Statescript编辑器所识别(即解析,反射),并在创建Wait State时读取Stu信息来将其以节点的方式可视化出来,这个Stu文件只是定义了Wait State内容,而没有规定其是如何运行的,所以还需要用C++专门为这个Wait State编写其运行时逻辑(即运行)。这个结构化数据系统可能听起来有点唬人,但其实就做了一套非常简单的事情,定义一个节点,解析一个节点,执行一个节点。和我们为行为树新增一个Action节点所做的事情是一样的

17 : 47 - 19 : 37:如何使用Statescript来做一个网络游戏?这部分主要说明了Statescript在开发技能过程中作出的思考和取舍,1.让Statescript使用者尽量对于网络同步无感知,所以守望先锋并没有区分客户端脚本和服务端脚本。2.快速响应,即客户端可以立即做出预测行为。3.安全性,即客户端不进行决定性的结果预测,服务器权威,Statescript不论在本地做出了怎样的预测行为,一旦服务器传回的状态与本地不一致,就需要回滚到上一个与服务器状态一致的帧,然后重新模拟到当前帧。4.高效,可以应对恶劣的网络环境。5.一体化,尽可能的减少使用者对于网络同步的感知。以上5点在基于事件的行为树中可以完美的实现。

19 : 37 - 40 : 09:Statescript在网络同步中的处理方案。主要讲解了OW的网络同步模型,比较重要一点是客户端只会预测本地玩家的行为,比如我在操作英雄A,另一个人在操作英雄B,那么我就只会预测英雄A,而英雄B的行为表现则完全依赖于服务器的回包。并且对于网络同步的细节做出了更加详尽的解读,包括基础的概念如玩家输入,RTT,Statescript系统产生的DeltaData(其实在NKGMoba中一样有行为树的黑板脏数据概念与之相对应),以及客户端对于本地玩家和Remote玩家完整的预测(仅本地玩家),回滚过程。随后通过一个简单的死神开枪的示例视频演示了网络同步的具体过程,并且提到了State提供了一些函数来处理来自服务器数据包导致的回滚行为,并且提到他们使用结构化镜像数据库来避免开发人员手写DeltaData搜集与发送代码,而是Statescript系统自动的进行DeltaData的收集,发送,处理工作(NKGMoba的做法与之相同,由行为树自己整理收集黑板脏数据然后进行发送和处理)。再然后举了一个猎空开枪的例子,例子中虽然Statescript系统正常的预测,回滚但是行为并没有正常的发生,因为这个示例中发射的子弹是抛物线类型的,而这种类型的子弹由一个Component单独管理,不归Statescript系统管理,所以如果想要在这种情况正常的回滚,就需要这个State节点自己去通知抛物线Component,让他进行回滚工作。

40 : 09 - 44 : 00:这部分跳出了具体的Statescript细节,从宏观的角度来看待整个Statescript系统,包括脏数据的范围定义,Statescript Graph中节点(Stu)的解析得到需要进行收集同步的数据等。随后举了一个猎空开枪的例子让观众感受在Client和Server上同一个Statescript Graph不同的执行逻辑,服务器上执行了几乎所有节点,但在客户端只执行了寥寥几个节点(个人猜测大部分还是表现相关的节点),这不仅仅提高了客户端性能,同时还减少了网络传输的数据量,减少了带宽(因为Statescript会分析每个节点Stu定义,得出需要同步的数据)。

44 : 00 - 结束:开始细数Statescript带来的好处以及未来需要解决的一些问题。首先好处是快速迭代新英雄,这是理所当然的,每个节点的课复用性是极强的,如果一个英雄特性不是特别的具有革命性,那么可以很大程度上利用节点库中已有的节点,然后Statescript开发者后期基本不需要书写网络同步相关代码,这被Statescript托管了。然后是后期仍要面临的一些挑战,首先是整个Statescript系统的开发工作量非常庞大,包括Stu脚本系统,调试器,运行时以及一些核心特性,他们花了3,4年来打磨这个Statescript系统。

(悄咪咪说一句,等状态帧同步战斗系统最后一期视频与大家见面的时候,大家应该能发现,几乎所有OW提及的Statescript特性在NKGMoba中都有对应的实现,并且完成度相当高!)