Blitz Engine和Battle Prime:ECS和网络代码



Battle Prime是我们工作室的第一个项目。 尽管团队的许多成员在开发游戏方面都具有不错的经验,但是我们在进行游戏时自然会遇到各种困难。 它们出现在引擎工作过程和游戏本身开发过程中。

在gamedev行业中,大量开发人员愿意以一种或另一种形式共享其故事,最佳实践和体系结构决策。 这种以文章,演示和报告的形式在公共空间中积累的经验,是思想和灵感的绝佳来源。 例如,《守望先锋》开发团队的报告对我们使用引擎非常有用。 像游戏本身一样,它们非常有才华,我建议所有有兴趣观看的人。 可在GDC保险库和YouTube中使用

这就是我们也想为共同事业做出贡献的原因之一-本文是专门研究开发Blitz Engine并使用它的技术细节的第一篇文章-Battle Prime。

本文将分为两个部分:

  • ECS:Blitz Engine内部的Entity-Component-System模式的实现。 本节对于理解本文中的代码示例很重要,它本身是一个单独的有趣主题。
  • 网络代码和游戏玩法:有关高级网络部分及其在游戏中的使用的所有信息-客户端-服务器体系结构,客户端预测,复制。 射击游戏中最重要的事情之一就是射击,因此将花费更多的时间。

在削减了很多兆的GIF!

在每个部分的内部,除了有关功能及其使用的故事外,我还将尝试描述其本身所存在的缺点-是它的局限性,工作上的不便,还是只是对将来的改进的想法。

我还将尝试给出代码示例和一些统计数据。 首先,它很有趣,其次,它提供了有关使用该功能或​​项目的规模的一些背景信息。

精英


在引擎内部,我们使用术语“世界”来描述包含对象层次结构的场景。

世界根据Entity-Component-System模板工作( 在Wikipedia上进行描述 ):

  • 实体-场景内的对象。 它是一组组件的存储库。 可以嵌套对象,从而在世界范围内形成层次结构;
  • 组件-是任何机械操作所需的数据,它确定对象的行为。 例如,“ TransformComponent”包含对象的转换,而“ DynamicBodyComponent”包含用于物理模拟的数据。 某些组件可能没有附加数据,它们在对象中的简单存在描述了此对象的状态。 例如,在Battle Prime中,使用了“ AliveComponent”和“ DeadComponent”,分别标记了活字符和死字符。
  • 系统-一组定期调用的功能,支持解决其任务。 对于每次调用,系统都会处理满足某种条件的对象(通常具有一组特定的组件),并在必要时对其进行修改。 所有游戏逻辑和大多数引擎都是在系统级别实现的。 例如,在引擎内部有一个“ LodSystem”,用于根据对象在世界上的变换和其他数据来计算对象的LOD(详细程度)索引。 然后,LodComponent中包含的索引将由其他系统用于其任务。

这种方法使在同一对象内组合不同的机制变得容易。 一旦实体收到足够的数据来完成某些机械师的工作,负责该机械师的系统就会开始处理该对象。

在实践中,添加新功能会减少到实现此功能的新组件(或组件集)和新系统(或系统集)。 在大多数情况下,采用这种模式很方便。

倒影


在进行组件和系统的描述之前,我将对反射机制进行一些介绍,因为它经常在代码示例中使用。

反射使您可以在应用程序运行时接收和使用有关类型的信息。 特别是,以下功能可用:

  • 根据特定条件(例如,类的继承人或具有特殊标记)获取类型列表,
  • 获取类字段的列表,
  • 获取类中的方法列表,
  • 获取枚举值列表,
  • 调用某些方法或更改字段的值,
  • 获取可用于特定功能的字段或方法的元数据。

引擎内部的许多模块出于自身目的使用反射。 一些例子:

  • 脚本语言的集成使用反射来处理C ++代码中声明的类型。
  • 编辑器使用反射来获取可以添加到对象中的组件列表,以及显示和编辑其字段。
  • 网络模块使用组件内部的字段元数据来实现多种功能:它们指示用于将字段从服务器复制到客户端,在复制过程中进行数据量化的参数;等等。
  • 使用反射将各种配置反序列化为相应类型的对象。

我们使用自己的实现,其接口与其他现有解决方案(例如, github.com/rttrorg/rttr )并没有太大不同。 使用CapturePointComponent的示例(描述游戏模式的捕获点),向类型添加反射如下所示:

//     class CapturePointComponent final : public Component { //            BZ_VIRTUAL_REFLECTION(Component); public: float points_to_own = 10.0f; String visible_name; // …   }; //   .cpp  BZ_VIRTUAL_REFLECTION_IMPL(CapturePointComponent) { //       ReflectionRegistrar::begin_class<CapturePointComponent>() [M<Serializable>(), M<Scriptable>(), M<DisplayName>("Capture point")] //      .field("points_to_own", &CapturePointComponent::points_to_own) [M<Serializable>(), M<DisplayName>("Points to own")] .field("visible_name", &CapturePointComponent::visible_name) [M<Serializable>(), M<DisplayName>("Name")] // …     } 

我要特别注意使用表达式声明的类型,字段和方法的元数据

 M<T>() 

其中“ T”是元数据的类型(在命令内部我们仅使用术语“ meta”,以后我将使用它)。 它们被不同的模块用于自己的目的。 例如,编辑器使用“ DisplayName”在编辑器中显示类型名称和字段,网络模块接收所有组件的列表,并在其中搜索标记为“ Replicable”的字段-它们将从服务器发送到客户端。

组件说明及其对对象的添加


每个组件都是基类“ Component”的继承者,并且可以借助反射来描述其使用的字段(如有必要)。

这就是在游戏内部声明和描述`AvatarHitComponent`的方式:

 /** Component that indicates avatar hit event. */ class AvatarHitComponent final : public Component { BZ_VIRTUAL_REFLECTION(Component); public: PlayerId source_id = NetConstants::INVALID_PLAYER_ID; PlayerId target_id = NetConstants::INVALID_PLAYER_ID; HitboxType hitbox_type = HitboxType::UNKNOWN; }; BZ_VIRTUAL_REFLECTION_IMPL(AvatarHitComponent) { ReflectionRegistrar::begin_class<AvatarHitComponent>() .ctor_by_pointer() .copy_ctor_by_pointer() .field("source_id", &AvatarHitComponent::source_id)[M<Replicable>()] .field("target_id", &AvatarHitComponent::target_id)[M<Replicable>()] .field("hitbox_type", &AvatarHitComponent::hitbox_type)[M<Replicable>()]; } 

该组件标记一个对象,该对象是由于一名玩家击中另一名玩家而创建的。 它包含有关此事件的信息,例如攻击玩家的标识符和他的目标,以及发生点击的点击框的类型。
简而言之,该对象是通过类似的方式在服务器系统内部创建的:

 Entity hit_entity = world->create_entity(); auto* const avatar_hit_component = hit_entity.add<AvatarHitComponent>(); avatar_hit_component->source_id = source_player_id; avatar_hit_component->target_id = target_player_id; avatar_hit_component->hitbox_type = hitbox_type; //      //      // ... 

然后,带有“ AvatarHitComponent”的对象将被不同的系统使用:播放击中玩家的声音,收集统计数据,跟踪玩家的成就等等。

系统及其工作的描述


系统是具有从“系统”继承的类型的对象,该系统包含实现特定任务的方法。 通常,一种方法就足够了。 如果必须在同一帧中的不同时间点执行几种方法,则它们是必需的。

类似于描述其领域的组件,每个系统都描述了世界应该执行的方法。

例如,负责爆炸的爆炸系统声明如下:

 // System responsible for handling explosive components: // - tracking when they need to be exploded: by timer, trigger zone etc. // - destroying them on explosion and creating separate explosion entity class ExplosiveSystem final : public System { BZ_VIRTUAL_REFLECTION(System); public: ExplosiveSystem(World* world); private: void update(float dt); //    ,     // ... }; BZ_VIRTUAL_REFLECTION_IMPL(ExplosiveSystem) { ReflectionRegistrar::begin_class<ExplosiveSystem>()[M<SystemTags>("battle")] .ctor_by_pointer<World*>() .method("ExplosiveSystem::update", &ExplosiveSystem::update)[M<SystemTask>( TaskGroups::GAMEPLAY_END, ReadAccess::set< TimeSingleComponent, WeaponDescriptorComponent, BallisticComponent, ProjectileComponent, GrenadeComponent>(), WriteAccess::set<ExplosiveComponent>(), InitAccess::set<ExplosiveStatsComponent, LocalExplosionComponent, ServerExplosionComponent, EntityWasteComponent, ReplicationComponent, AbilityIdComponent, WeaponBaseStatsComponent, HitDamageStatsComponent, ClusterGrenadeStatsComponent>(), UpdateType::FIXED, Vector<TaskOrder>{ TaskOrder::before(FastName{ "ballistic_update" }) })]; } 

系统描述中指示了以下数据:

  • 系统所属的标签。 每个世界都包含一组标签,在这些标签上应该是该世界上可以运行的系统。 在这种情况下,“战斗”标签表示玩家之间进行战斗的世界。 标签的其他示例包括“服务器”和“客户端”(系统分别仅在服务器或客户端上运行)和“渲染”(系统仅以GUI模式运行);
  • 执行该系统的组以及该系统使用的组件列表-用于编写,阅读和创建;
  • 更新类型-此系统是否应以常规更新,固定更新或其他方式工作;
  • 系统之间的显式权限依赖性。

有关系统组,依赖项和更新类型的更多信息将在下面描述。

全世界都在正确的时间调用了已声明的方法来维护该系统的功能。 方法的内容取决于系统,但是通常,这是对符合此系统标准的所有对象及其后续更新的遍历。 例如,更新游戏内部的“ ExplosiveSystem”如下:

 void ExplosiveSystem::update(float dt) { const auto* time_single_component = world->get<TimeSingleComponent>(); // Init new explosives for (Component* component : new_explosives_group->components) { auto* explosive_component = static_cast<ExplosiveComponent*>(component); init_explosive(explosive_component, time_single_component); } new_explosives_group->components.clear(); // Update all explosives for (ExplosiveComponent* explosive_component : explosives_group) { update_explosive(explosive_component, time_single_component, dt); } } 

上例中的组(“ new_explosives_group”和“ explosives_group”)是辅助容器,可简化系统实现。 new_explosives_group是一个容器,其中包含该系统必需的且从未处理过的新对象,而explosives_group是一个容器,其中包含每个帧都需要处理的所有对象。 世界直接负责填充这些容器。 系统对它们的接收发生在其构造函数中:

 ExplosiveSystem::ExplosiveSystem(World* world) : System(world) { // `explosives_group`        `ExplosiveComponent` explosives_group = world->acquire_component_group<ExplosiveComponent>(); // `new_explosives_group`        //  `ExplosiveComponent` -       new_explosives_group = explosive_group->acquire_component_group_on_add(); } 

世界更新


世界是类型为“世界”的对象,每个框架都在许多系统中调用必要的方法。 将调用哪个系统取决于它们的类型。

在系统的一部分中,每个框架都必须进行更新(在引擎内部使用术语“正常更新”)-这种类型包括影响框架和声音渲染的所有系统:骨骼动画,粒子,UI等。 另一部分以固定的预定频率执行(我们使用术语“固定更新”,并且表示每秒的固定更新数量-FFPS)-它们处理大多数游戏逻辑以及客户端和服务器之间需要同步的所有内容-例如,玩家输入的一部分,角色移动,射击以及物理模拟的一部分。



固定更新的执行频率应该保持平衡-值太小会导致游戏响应无响应(例如,玩家的输入处理频率较低,因此延迟时间更长),并且频率过高-导致运行应用程序的设备对性能的要求很高。 这也意味着频率越高,服务器容量的成本就越高(在同一台计算机上可以同时进行的战斗越少)。

在下面的gif中,世界以每秒5次固定更新的频率运行。 您会注意到按下W按钮与开始移动之间的延迟,以及释放按钮与停止字符移动之间的延迟:

图片

在下一个gif中,世界以每秒30次固定更新的频率运行,这将显着提高响应速度:

图片

目前,在Battle Prime固定更新中,世界每秒运行31次。 这种“丑陋”的值是特别选择的-当每秒更新的数量是例如整数或屏幕刷新率的倍数时,它可能会导致在其他情况下不存在的错误。

系统执行命令


使ECS工作复杂化的一件事是执行系统的任务。 就上下文而言,在撰写本文时,在玩家之间的战斗中,Battle Prime客户端中有251系统,并且其数量还在不断增加。

在错误的时间错误执行的系统可能会导致细微的错误,或者导致某些机械师在一帧中的操作延迟(例如,如果损坏系统在帧的开头起作用,而射弹飞行系统在结尾时起作用,则表示损坏了)延迟一帧)。

可以通过多种方式设置系统的执行顺序,例如:

  • 明确订购
  • 指示系统的数字“优先级”,然后按优先级排序;
  • 自动构建系统之间的依赖关系图,并将它们按执行顺序安装在正确的位置。

目前,我们正在使用第三个选项。 每个系统都指示它用于读取,写入的组件以及创建的组件。 然后,系统按照必要的顺序自动排列在它们之间:
  • 系统读取组件A在系统写入组件A之后出现;
  • 写入或读取组件B的系统位于创建组件B的系统之后。
  • 如果两个系统都写入组件C,则顺序可以是任意顺序(但可以在需要时手动指定)。

从理论上讲,这样的解决方案使对执行顺序的控制最小化;所需要做的只是为系统设置组件掩码。 实际上,随着项目的发展,这导致系统之间的循环越来越多。 如果系统1写入组件A,然后读取组件B,而系统2读取组件A,然后写入组件B,则这是一个周期,必须手动解决。 通常,一个周期中有两个以上的系统。 他们的解决方案需要时间和对它们之间关系的明确指示。

因此,Blitz Engine具有“组”系统。 在组内部,系统会按所需顺序自动排列(并且仍然可以手动解决周期),并且显式设置了组的顺序。 该决定是完全手动命令和完全自动命令之间的交叉,并且小组的规模严重影响了其有效性。 一旦组变得太大,程序员经常会再次遇到其中的循环问题。

目前在Battle Prime中有10个小组。 这还远远不够,我们计划通过在它们之间建立严格的逻辑顺序并在它们每个内部使用自动构建图形来增加它们的数量。

指出系统将使用哪些组件来进行写入或读取,将来还将允许将系统自动分组为将彼此并行执行的“块”。

下面是一个辅助实用程序,它显示每个组内系统的列表以及它们之间的依赖性(组内的完整图形看起来令人生畏)。 橙色显示系统之间明确定义的依赖关系:

图片

系统及其配置之间的通信


系统在其内部执行的任务在某种程度上可以取决于其他系统的结果。 例如,处理两个物体碰撞的系统取决于记录这些碰撞的物理模拟。 损坏系统取决于弹道系统的结果,该系统负责弹壳的运动。

系统之间最简单,最明显的通信方式是使用组件。 第一个系统将其工作结果添加到组件中,第二个系统从组件中读取这些结果并根据它们解决问题。

在某些情况下,基于组件的方法可能不方便:

  • 如果系统结果未直接绑定到某个对象怎么办? 例如,一个收集战役统计信息(枪击,击中,死亡人数等)的系统-根据整个战役在全球范围内进行收集;
  • 如果需要以某种方式配置系统怎么办? 例如,物理仿真系统需要知道哪些类型的对象应该记录它们之间的碰撞,而哪些不是。

为了解决这些问题,我们使用了从《守望先锋》开发团队借来的方法-Single Components。

单一组件是一个存在于世界中的组件,可以直接从世界中获取。 系统可以使用它来汇总其工作结果,然后供其他系统使用,或配置其工作。

目前,该项目(引擎模块+游戏)具有约120个单一组件,可用于各种目的-从存储全球世界数据到单个系统的配置。

“清洁”方法


以其最纯粹的形式,这种对系统和组件的方法仅要求组件内数据的可用性以及仅系统内逻辑的存在。 我认为,在实践中,严格遵守这一限制几乎是没有道理的(尽管仍在定期对此问题进行辩论)。

可以强调以下主张不太严格的方法的论点:

  • 应该共享部分代码-并从不同的系统或设置组件的某些属性时同步执行。 单独描述类似的逻辑。 作为引擎的一部分,我们使用术语Utils。 例如,在游戏中,“ DamageUtils”包含与施加伤害相关的逻辑-可以从不同的系统施加伤害;
  • 将系统的私有数据保留在系统本身以外的其他地方没有任何意义-除了它之外,没有人会需要它,并且将其移至另一个地方并不是特别有用。 该规则有一个例外,它与客户预测的功能有关-将在下面的部分中进行介绍;
  • 组件具有少量逻辑是很有用的-在大多数情况下,这些是聪明的获取器和设置器,可简化组件的使用。

网络代码


Battle Prime使用具有威权服务器和客户端预测的架构。 这使玩家即使在高ping和丢包情况下,也从整个项目中,都可以从他们的动作中获得即时反馈,从而最大程度地减少了玩家的作弊行为,因为 服务器决定战斗中的所有模拟结果。

游戏项目中的所有代码分为三个部分:

  • 客户端-仅在客户端上工作的系统和组件。 其中包括用户界面,自动拍摄和插值;
  • 服务器-仅在服务器上工作的系统和组件。 例如,所有与伤害和生成角色有关的事物;
  • 常规-这就是在服务器和客户端上都能使用的所有功能。 特别是,所有用于计算角色移动,武器状态(回合数,冷却时间)的系统以及需要在客户端上预测的所有其他系统。 大多数负责视觉效果的系统也是常见的-可以选择以GUI模式启动服务器(大多数情况下仅用于调试)。

用户输入(输入)


在继续进行有关客户机的复制和预测的详细信息之前,您应该先研究引擎内部的输入-在以下各节中,这些信息的重要性将很重要。

播放器的所有输入都分为两种:低级和高级:

  • 底层输入-这些是来自输入设备的事件,例如按键,触摸屏幕等。 这样的输入很少由游戏系统处理;
  • 高级输入-是用户在游戏中所执行的动作:射击,武器更换,角色移动等。 对于此类高级操作,我们使用术语“操作”。 而且,其他数据可以与动作相关联,例如运动方向或所选武器的索引。 绝大多数系统都可以使用Actions。

可以根据来自低级别输入的绑定程序或以编程方式生成高级输入。 例如,射击动作可以与鼠标单击关联,也可以由负责自动射击的系统生成-一旦玩家瞄准敌人,如果用户启用了相应的设置,该系统就会生成动作射击。 动作也可以由UI系统发送:例如,通过按下相应的按钮或移动屏幕上的操纵杆。 触发的系统与创建此动作无关。

逻辑上相关的动作被分组在一起(类型为“ ActionSet”的对象)。 如果在当前上下文中不需要分组,可以将其断开连接-例如,在Battle Prime中,有多个分组,其中:

  • 控制角色移动的动作,
  • 发射自动武器的动作,
  • 发射半自动武器的动作。

在后两组中,一次仅一次处于活动状态,具体取决于所选武器的类型-它们在执行“火”动作的方式上有所不同:按下按钮时(对于自动武器)或仅按下一次时(对于半自动武器) )

同样,在以下系统之一的游戏中创建并配置动作组:

 static const Map<FastName, ActionSet> action_sets = { { //     ControlModes::CHARACTER_MOVEMENT, ActionSet { { DigitalBinding{ ActionNames::JUMP, { { InputCode::KB_SPACE, DigitalState::just_pressed() } }, nullopt }, DigitalBinding{ ActionNames::MOVE, { { InputCode::KB_W, DigitalState::pressed() } }, ActionValue{ AnalogState{0.0f, 1.0f, 0.0f} } }, //    ... }, { AnalogBinding{ ActionNames::LOOK, InputCode::MOUSE_RELATIVE_POSITION, AnalogStateType::ABSOLUTE, AnalogStateBasis::LOGICAL, {} } //    ... } } }, { //       ControlModes::AUTOMATIC_FIRE, ActionSet { { // FIRE    ,      DigitalBinding{ ActionNames::FIRE, { { InputCode::MOUSE_LBUTTON, DigitalState::pressed() } }, nullopt }, //       ... } } }, { //       ControlModes::SEMI_AUTOMATIC_FIRE, ActionSet { { // FIRE          DigitalBinding{ ActionNames::FIRE, { { InputCode::MOUSE_LBUTTON, DigitalState::just_pressed() } }, nullopt }, //       ... } } } //   ... }; 

《 Battle Prime》描述了约40种动作。其中一些仅用于调试或记录剪辑。

复写


复制是将数据从服务器传输到客户端的过程。所有数据都是通过世界上的对象传输的:

  • 他们的创建和删除,
  • 在对象上创建和删除组件,
  • 更改组件属性。

使用适当的组件配置复制。例如,通过类似的方法,游戏设置了玩家武器的复制:

 auto* replication_component = weapon_entity.add<ReplicationComponent>(); replication_component->enable_replication<WeaponDescriptorComponent>(Privacy::PUBLIC); replication_component->enable_replication<WeaponBaseStatsComponent>(Privacy::PUBLIC); replication_component->enable_replication<WeaponComponent>(Privacy::PRIVATE); replication_component->enable_replication<BallisticsStatsComponent>(Privacy::PRIVATE); // ...    

对于每个组件,指示复制期间使用的隐私。私有组件将仅从服务器发送给拥有此武器的玩家。公共组件将发送给所有人。在此示例中,“ WeaponDescriptorComponent”和“ WeaponBaseStatsComponent”是公共的-它们包含正确显示其他玩家所需的数据。例如,动画需要武器所在的插槽的索引及其类型。剩余的组件会私下发送给拥有此武器的玩家-弹道的参数,炮弹总数信息,可用的射击模式等。有更多专用的隐私模式:例如,您只能将组件发送给盟友或仅发送给敌人。

其说明中的每个组件都必须指出应在该组件中复制哪些字段。例如,“武器组件”内的所有字段都标记为“可复制”:

 BZ_VIRTUAL_REFLECTION_IMPL(WeaponComponent) { ReflectionRegistrar::begin_class<WeaponComponent>() .ctor_by_pointer() .copy_ctor_by_pointer() .field("owner", &WeaponComponent::owner)[M<Replicable>()] .field("fire_mode", &WeaponComponent::fire_mode)[M<Replicable>()] .field("loaded_ammo", &WeaponComponent::loaded_ammo)[M<Replicable>()] .field("ammo", &WeaponComponent::ammo)[M<Replicable>()] .field("shooting_cooldown_end_ms", &WeaponComponent::shooting_cooldown_end_ms)[M<Replicable>()]; } 

这种机制使用起来非常方便。例如,在负责从被杀死的对手“弹出”令牌的服务器系统内部(在特殊游戏模式下),在这样的令牌上添加和配置“ ReplicationComponent”就足够了。看起来像这样:

 for (const Component* component : added_dead_avatars->components) { Entity kill_token_entity = world->create_entity(); //           // ... //   auto* replication_component = kill_token_entity.add<ReplicationComponent>(); replication_component->enable_replication<TransformComponent>(Privacy::PUBLIC); replication_component->enable_replication<KillTokenComponent>(Privacy::PUBLIC); } 

在此示例中,丢失期间令牌的物理模拟将在服务器上进行,令牌的最终转换将发送并应用于客户端。插值系统也将在客户端上工作,这将考虑更新频率,与服务器的连接质量等因素,从而使此令牌的移动变得平稳。与该游戏模式关联的其他系统将使用“ KillTokenComponent”向对象添加视觉部件并监视其选择。

您要注意并在将来要摆脱的当前方法的唯一不便之处是无法为每个组件字段设置隐私。这不是很关键,因为可以通过将组件拆分为多个组件来轻松解决类似的问题:例如,游戏包含具有相应隐私权的“ ShooterPublicComponent”和“ ShooterPrivateComponent”。尽管它们与一种机制联系在一起(射击),但仍需要有两个组件来节省流量-不拥有这些组件的客户端根本不需要某些字段。但是,这增加了程序员的工作量。

通常,复制到客户端的对象可以具有不同帧的状态。因此,添加了通过形成复制组对对象进行分组的功能。同一组内的对象上的所有组件在客户端上始终具有相同帧的状态-这对于预测正确起作用是必要的(下面将详细介绍)。例如,武器和拥有该武器的角色在同一组中。如果对象位于不同的组中,则它们在世界中的状态可能针对不同的帧。

复制系统特别是通过压缩传输的数据(组件中的每个字段可以相应地标记以进行压缩)以及通过仅传输两个帧之间的值差来尝试最小化通信量。

客户预测


客户预测(客户端预测一词用英语表示)允许玩家收到有关其在游戏中大多数动作的即时反馈。同时,由于最后一个字始终在服务器后面,因此如果发生模拟错误(术语“错误预测”用英语使用,将来我将其简称为“错误预测”),客户端必须对其进行修复。下面将描述有关预测误差及其纠正方法的更多细节。

客户预测根据以下规则进行:

  • 客户模拟自己向前N帧;
  • 客户端生成的所有输入都将发送到服务器(以播放器执行的动作的形式);
  • N取决于与服务器的连接质量。该值越小,代表客户的世界情况就越“新鲜”(即,本地参与者与其他参与者之间的时间间隔较小)。

结果,服务器和客户端都基于客户端输入执行仿真。然后,服务器将模拟结果发送给客户端。如果客户端确定其结果与服务器的结果不一致,则它将尝试纠正错误-将自身回滚到最后一个已知的服务器状态,并再次模拟前面的N帧。然后,一切按照相似的方案继续进行-客户端在将来继续针对服务器进行自身仿真,然后服务器将其仿真结果发送给服务器。因此,所有影响客户端预测的代码都必须在客户端和服务器之间共享。

此外,为了节省流量,整个输入将基于预定义的方案进行预压缩。然后将其发送到服务器,并立即解压缩回客户端。为了消除与客户端和服务器之间的输入关联的值的差异,必须对客户端进行打包和随后的拆包。创建方案时,将指示此操作的值范围以及应打包到其中的位数。同样,Battle Prime中的打包方案的声明就像在客户端和服务器之间的通用系统内部一样:

 auto* input_packing_sc = world->get_for_write<InputPackingSingleComponent>(); input_packing_sc->packing_schema = { { ActionNames::MOVE, AnalogStatePrecision{ 8, { -1.f, 1.f }, false } }, { ActionNames::LOOK, AnalogStatePrecision{ 16, { -PI, PI }, false } }, { ActionNames::JUMP, nullopt }, // ..    action' }; 

客户预测工作正常运行的一个关键条件是,在输入与之相关的帧模拟之前,输入必须有时间到达服务器。如果输入未能在所需的帧上到达服务器(例如,急剧的ping跳可能会发生这种情况),则服务器将尝试使用前一帧中该客户机的输入。这是一种备份机制,在某些情况下可以帮助消除客户端上的错误预测。例如,如果客户端仅在一个方向上运行并且其输入在相当长的时间内没有变化,则最后一帧的输入使用将成功-服务器将对其进行``猜测'',并且客户端与服务器之间不会存在差异。 《守望先锋》中使用了类似的方案(在有关GDC的演讲中提到:www.youtube.com/watch?v=W3aieHjyNvw)。

当前,Battle Prime客户端可以预测以下对象的状态:

  • 玩家头像(在世界上的位置以及可能影响其的一切,技能状态等);
  • 玩家的所有武器(商店中的回合数量,射门之间的冷却时间等)。

使用客户端预测可以归结为在客户端上向所需对象添加和配置“ PredictionComponent”。例如,以类似的方式打开对其中一个系统中玩家头像的预测:

 // `new_local_avatars`       , //      for (Entity avatar : new_local_avatars) { auto* avatar_prediction_component = avatar.add<PredictionComponent>(); avatar_prediction_component->enable_prediction<TransformComponent>(); avatar_prediction_component->enable_prediction<CharacterControllerComponent>(); avatar_prediction_component->enable_prediction<ShooterPrivateComponent>(); avatar_prediction_component->enable_prediction<ShooterPublicComponent>(); // ...      } 

此代码意味着将不断将上述组件内的字段与服务器组件的类似字段进行比较-如果发现单个帧内的值存在差异,则会在客户端上进行调整。

差异标准取决于数据类型。在大多数情况下,这只是对`operator ==`的调用,例外情况是基于float的数据-对于它们,最大允许错误当前已固定且等于0.005。将来,希望增加能够分别设置每个分量字段的精度的能力。

复制和客户端预测工作流基于以下事实:模拟所需的所有数据都包含在组件中。上面,在有关ECS的部分中,我写道,允许系统保留部分数据-在某些情况下这很方便。这不适用于影响模拟的任何数据-它必须始终在组件内部,因为客户端和服务器快照系统仅与组件一起使用。

除了预测组件内的字段值外,还可以预测组件的创建和删除。例如,如果由于使用该能力而在角色上叠加了“ SpeedModifierComponent”(这会改变移动速度,例如,加快玩家的速度),则必须将其同时添加到服务器和客户端的同一帧上的角色上,否则会导致错误预测角色在客户端上的位置。

当前不支持预测对象的创建和删除。在某些情况下这可能很方便,但也会使网络模块复杂化。也许我们将来会回到这一点。

下面是一个gif,其中使用RTT进行字符控制大约1.5秒。如您所见,尽管存在高延迟,但角色还是可以立即得到控制:移动,射击,重新装填,投掷手榴弹-一切都在发生,而无需等待服务器提供的信息。您还可以注意到,捕获点(由三角形限制的区域)的开始是有延迟的-这种机制仅适用于服务器,而客户端无法预测。

图片

错误的预测和重新模拟


错误预测-服务器和客户端模拟的结果之间存在差异。重新模拟是客户端纠正此差异的过程。

出现错误预测的第一个原因是突然的ping跳动,客户没有时间进行调整。在这种情况下,来自播放器的输入可能没有时间到达服务器,并且服务器将使用上述备份机制,并将最后一次输入复制一段时间,然后过一会儿它将停止使用。

第二个原因是角色与完全由服务器控制且客户端未在本地预测的对象之间的交互。例如,与另一个玩家的碰撞将导致错误的预测-因为他们实际上生活在两个不同的时间段中(本地角色相对于另一个玩家而言是未来的角色-其位置来自服务器并由插值)。

第三个也是最不愉快的原因是代码中的错误。例如,系统可能错误地使用非复制数据来控制模拟,或者系统以错误的顺序工作,甚至在服务器和客户端上以不同的顺序工作。

找到这些错误有时需要花费大量时间。为了简化搜索,我们制作了几种辅助工具-在应用程序运行时,您可以看到:

  • 复制组件
  • 错误预测的次数
  • 它们发生在哪些帧上,
  • 服务器和客户端上不同组件中的数据是什么,
  • 为此帧在服务器和客户端上应用了什么输入。




不幸的是,即使有了它们,寻找重新模拟的原因仍然花费相当长的时间。无疑,需要开发工具和验证,以减少错误的可能性并简化其搜索。

为了支持重新仿真的操作,系统必须从特定的类“ ResimulatableSystem”继承。在发生错误预测的情况下,世界将所有对象“回滚”到最后一个已知的服务器状态,然后提前进行必要数量的模拟以解决此错误-只有可重新模拟的系统会参与其中。

通常,玩家的模拟不会引起客户的注意。当它们出现时,所有分量字段都被平滑地插值为新值,以在视觉上平滑可能的“跳动”。但是,至关重要的是要使其数量尽可能少。

射击


服务器对玩家的损害完全由服务器决定-不能以如此重要的机制来信任客户以减少作弊的可能性。但是,就像动作一样,在客户端上射击应该尽可能快且无延迟-玩家需要以效果和声音的形式接收即时反馈-枪口闪光,弹丸飞行的轨迹以及弹丸撞击周围环境和其他玩家的效果。

因此,客户可以预测与射击相关的角色的整个状态-商店中有多少回合,射击期间的散布,射击之间的延迟,最后一次射击的时间等等。客户端上也有与服务器上相同的负责弹壳移动的系统-这使您可以在客户端上模拟镜头,而不必等待服务器上的模拟结果。

弹壳本身的弹道是无法预测的-因为它们以很高的速度飞行,并且通常在几帧内完成其运动,所以弹壳已经有时间到达世界某个点并在获得模拟结果之前失去效果这是来自服务器的射弹(如果由于错误的预测,客户端错误地发射了射弹,则没有结果)。

缓慢飞行的弹丸的工作方案略有不同。如果玩家投掷了手榴弹,但由于错误的预测,结果证明未投掷手榴弹,则它将在客户端上被销毁。同样,如果客户端错误地预测了手榴弹的销毁(它已经在服务器上爆炸,但尚未在客户端爆炸),则客户端手榴弹也将被销毁。客户端上显示的有关爆炸的所有信息都来自服务器,以避免由于客户端错误而导致服务器爆炸发生在一个位置而客户端发生在另一个位置的情况。

理想情况下,我想完全预测未来会缓慢飞行的贝壳-不仅是生存时间,还包括它们的位置。

滞后补偿


滞后补偿是一项技术,可让您在服务器和客户端之间延迟延迟对拍摄准确性的影响。在本节中,我将假定射击总是来自“ hitscan”武器-即 用武器发射的弹丸以无限的速度行进。但是这里描述的所有内容也与其他类型的武器有关。

以下几点使得有必要在拍摄时补偿延迟:

  • 在玩家控制下的角色是相对于服务器而言的未来(预测其状态在特定帧数之前);
  • 因此,其他球员与他过去有关系。
  • 触发后,客户端会将相应的操作发送到服务器,并在与客户端上应用的操作相同的帧上应用(如果可能)。

如果我们假设玩家瞄准的是奔向头部的敌人并按下射击按钮,则可获得以下图片:

  • 在客户端上:N1帧上的射击者向N0帧上的敌人的头部射击(N0 <N1);
  • 在服务器上:第N1帧上的射击者向也位于第N1帧上的敌人的头部射击(在服务器上,所有对象都在同一时间)。

这样的结果很有可能是击球时的失误。由于客户是根据自己的世界图景而不是服务器的世界图景来瞄准敌人的,为了进入敌人,即使使用命中扫描武器,他也需要瞄准他,而他必须射击的前方距离取决于与之连接的质量。服务器。坦率地说,这对于射击者来说不是一个好经验。

为了解决这个问题,使用了滞后补偿。她的工作计划如下:

  • 该服务器保存的快照数量有限。
  • 当被发射时,敌人(或部分敌人)以某种方式“回滚”,以使服务器上的世界与客户自己看到的世界相对应-客户处于“现在”(射击时刻),而敌人位于过去;
  • 命中检测机制工作,记录命中;
  • 世界正在恢复其原始状态。

由于客户端上的世界情况还取决于插值系统的操作,为了将世界“回滚”到服务器上最准确的客户端状态,客户端会向他发送其他数据-客户端当前帧与他看到的所有其他玩家帧之间的差(目前每帧两个字节),以及相对于帧开始的镜头输入生成时间。

滞后补偿存在于发动机内部一个单独模块的级别,并且与特定项目无关。从游戏机制开发者的角度来看,其用法如下:

  • 将LagCompensationComponent添加到播放器,并填充要存储在历史记录中的命中框列表;
  • 当射击(或其他需要补偿的机制,例如在近战攻击中)时,会调用LagCompensation :: invoke,在函子传递的位置,从特定玩家,世界的角度来看,该函子将在“ compensated”中执行。它必须具有所有必要的命中检测。

在移动弹道导弹时使用Batle Prime的滞后补偿的示例代码:

 // `targets_data`    , //   “”    , //    const auto compensated_action = [this](const Vector<LagCompensation::LagCompensationData>& targets_data) { process_projectile(projectile, elapsed_time); }; LagCompensation::invoke( observer, // ,       projectile_component->input_time_ms, // ,      compensated_entities, // ,    compensated_action // ,       ); 

我还要指出的是,滞后补偿是一种使射手的经验高于他所射击目标的经验的方案。从目标的角度来看,敌人可以在他已经处于障碍物后面的时候进入他体内(在游戏论坛中经常抱怨)。为此,滞后补偿具有数量有限的帧,可以针对这些帧“输出”目标。目前,在Battle Prime中,RTT约为400毫秒的射击者可以轻松击中敌人。如果RTT较高,则必须向前射击。

无偿射击的示例-您需要向前射击以稳定地打击敌人:

图片

有了补偿-您可以轻松地直接瞄准敌人:

图片

我们的构建代理还定期运行自动测试,以检查不同机制的工作情况。其中,在启用滞后补偿的情况下,还可以自动测试点火精度。在下面的gif中,显示了此测试-角色简单地射击了一个敌人跑过去的头并计算了击中他的次数。为了进行调试,还显示了射击时服务器上敌方的命中盒(白色)和用于补偿世界中命中检测的命中盒(蓝色):

图片

影响射击准确性的另一个因素是角色上的命中框的位置。 Hitbox取决于骨骼动画,并且它们的阶段当前不以任何方式同步,因此,客户端和服务器之间的Hitbox可能会有所不同。其结果取决于动画本身-动画内部的运动范围越大,服务器和客户端之间的点击框位置的潜在差异就越大。在实践中,这种差异对于玩家来说并不明显,并且对下半身的影响更大,与上半身(头部,躯干,手臂)相比,下半身的紧要程度较小。尽管如此,将来我想更详细地讨论服务器与客户端之间的动画同步问题。

结论


在本文中,我试图描述Battle Prime的构建基础-这是Blitz Engine内部ECS模式的实现,以及负责复制,客户端预测和相关机制的网络模块。尽管存在一些缺陷(我们将继续解决这些缺陷),但现在使用此功能既简单又方便。

为了展示Battle Prime的整体情况,我不得不涉及很多主题。他们中的许多人可能会在将来专门讨论单独的文章,在其中将对其进行详细说明!

该游戏已经在土耳其和菲律宾进行了测试。

我们以前的文章可以在以下链接中找到:

  1. habr.com/zh/post/461623
  2. habr.com/zh/post/465343

Source: https://habr.com/ru/post/zh-CN469709/


All Articles