谈一谈弱网络下手游的网络同步(二)

上一篇文章主要介绍了手机游戏在弱网络下抵抗网络抖动的解决方案以及传统帧同步在手机游戏上的应用。这一篇主要包括两个部分,第一部分为ECS架构(Entity-Component-System),第二部分ECS架构下状态同步的解决方案以及UE4引擎的序列化与反序列化。

  • 什么是ECS架构

首先先放上一个unity的ECS插件:sschmid/Entitas-CSharp,感兴趣的同学可以参考源码,虽然是Unity的插件,但是也可以改成C++然后集成进UE4。

ECS架构目前在游戏开发中可以说是一个比较先进的框架,它是建立在渲染、物理引擎之上的一个GameCore框架,看过GDC 2017的朋友应该知道守望先锋就是基于这么一个框架开发的。传统面向对象框架在大型游戏开发中,会带来很多维护上的问题,比如类数量庞大,耦合严重。ECS框架的引入目的就是为了进一步解耦,把实体行为和状态分离。

  1. 行为和状态分离的好处?

在逻辑和表现分离的游戏中,实体行为实际上就是表现层的东西,比如角色的移动、跳跃、攻击、特效等,状态就是实体的属性数据,行为和状态的分离对网络同步十分友好,因为我们只需要关注如何让客户端和服务器逻辑和数据保持一致,而不需要管表现层是怎么样的。

2. 行为和状态如何分离?

Entity代表了游戏中所有实体的抽象,System是实体行为的抽象而Component是实体属性数据的抽象。打个比方一个角色实际上就是一个Entity,他有一个System叫做MovementControlSystem来专门负责控制角色的移动,同时还有一个MovementComponent来专门负责维护角色移动属性数据,比如速度、方向、加速度等。

对于System来说,它不需要保存任何有关角色的数据,因为它定义的只是一组行为(比如Sprint, Walk, Crawl等),而Component只维护数据而不定义任何行为,所以一个Entity可以通过组合任意的System和Component来为实体添加不同的功能,而每个Component之间是相互独立的。

3. ECS架构下的网络同步

一个体验好的网络同步框架肯定是要有预测的,预测意味着客户端操作先行,不必等服务器。一旦有预测就意味着预测肯定会有失败的情况,那么如何正确的回滚+前滚就是网络同步需要考虑的问题。

在ECS架构下由于,我们仅仅只需要关注Component中的数据,保证Component的正确回滚+前滚即可。

  • 状态同步
  1. 什么是状态同步?

在帧同步中,服务器转发每一个客户端发来的命令帧到其他客户端,一般来说服务器本身不做计算。状态同步和帧同步不同的是,状态同步中客户端和服务器之间需要同步的是状态,服务器收到客户端上行的命令帧,必须要计算好客户端的状态,并将正确的状态下发到所有客户端,也就是说服务器具有仲裁权。

在这里我们举一个基于Snapshot状态同步的例子。

服务器和客户端都会维护一个buffer。在客户端上,这个buffer保存了未经服务器确认的客户端的全量快照数据,每一个全量快照数据都包含了逻辑帧号,以便客户端收到服务器下发的快照之后能找到对应的客户端快照数据,找到之后进行snapshot的对比,如果误差较大,那么需要对客户端进行回滚,这里只需要将服务器snapshot中的数据覆盖掉客户端上的数据即可,然后再从snapshot帧号前滚到当前客户端的帧号,最后将该帧号所对应的snapshot从buffer中删除。

如果服务器有预测的话(服务器饥饿的情况下会发生服务器预测客户端操作)那么服务器也会有回滚+前滚的操作,那么服务器也需要有一个buffer来缓存每一个逻辑帧对应的服务器snapshot。在守望先锋开发团队的分享中,他们的服务器预测方案中有时间膨胀的特性,如果服务器发生频繁的预测,意味着客户端与服务器的网络连接较差,客户端应当提高逻辑帧率来加速发包,使得服务器能更快更多得收到客户端的包,等到服务器的预测几率降低之后,客户端再降低逻辑帧率减速发包。

2.状态同步的难点

在状态同步中,服务器要不断下发每个客户端当前的最新状态,并replicate到所有others,这种方式下的流量消耗比帧同步大得多,所以状态同步的流量优化是难点之一。怎么样有效的剔除掉没有必要下发的快照是减少流量需要考虑的问题。其次,流量优化也可以从字符串压缩方面入手,减少字符串的传输,用ID来代替字符串也能有效的减小流量开销。

另一方面,状态同步中预测失败后需要回滚,那么数据回滚之后并不一定能保证状态的正确,怎么样重新打通执行流程是状态同步中一个比较棘手的问题。状态同步与帧同步不同的是,状态同步中每一个客户端需要服务器下发的数据都可能不同,所以开发者需要对每个Local entity编写不同的代码。

状态同步的回放系统相比帧同步也会更麻烦,客户端本地无法知道全量操作序列。一般的解决方案是在服务器在运行过程中会定时对单局内的所有客户端做一次全量快照,并缓存到该段时间间隔内所有客户端的命令帧。回放时,只需要针对全量数据用命令帧进行模拟即可。

3. ECS架构下的状态同步

在ECS架构下,每一个客户端都有一个Local Entity以及若干个Remote Entity ,Local Entity代表当前客户端所控制的本地entity。对于服务器下发的RemoteEntity状态信息,客户端仅仅需要覆盖更新即可,对于Local Entity状态信息,由于客户端的Local Entity实际上是领先于服务器的,所以首先要检查是否需要回滚。在ECS架构下,System是数据无关的,所以它不需要回滚,那么Local Entity的回滚实际上就是Components的回滚。

Local Entity的回滚,回滚所有的Component即可:

void Entity::RollBack(UECSEntity* EntitySnapshot) 
{
    for(int32 i = 0; i < Components.Num(); ++i)
    {
        Components[i]->RollBack(EntitySnapshot->Components[i]);
    }
}

 MoveComponent的回滚,这里需要从Snapshot中恢复出速度和位置等信息:

void UECSMoveComponent::RollBack(UECSComponent* ComponentSnapshot)
{
    UECSMoveComponent* MoveComponentSnapshot = Cast<UECSMoveComponent>(ComponentSnapshot);
    if(MoveComponentSnapshot )
    {
        // Restore speed
        CurrentSpeed = MoveComponentSnapshot ->CurrentSpeed;
       
        // Restore position
        CurrentPosition = MoveComponentSnapshot->CurrentPosition;
    }
}

所有的Component回滚之后还需要快速播放到当前客户端所处的位置:

/*
 *  StartFrameIndex 起始逻辑帧号, EndFrameIndex 结束逻辑帧号 
 */
void Entity::ForwardPlay(uint32 StartFrameIndex, uint32 EndFrameIndex)
{
    for(uint32 i = StartFrameIndex; i <= EndFrameIndex; ++i)
    {
        // Execute command frame
        ExecCmdFrame(CachedCmdFrames[i]);

        // Inject tick 
        Tick(LogicWorld->GetFixedDeltaTime());

        // Cook current client snapshot
        FSnapshotData* ClientSnapshot = CookClientSnapshot();
        
        // Update the buffer
        CachedSnapshots[i] = ClientSnapshot;
    }
    
    // Remove the start snapshot
    CachedSnapshots.RemoveAt(StartFrameIndex);
    
    // Remove the start command frame
    CachedCmdFrames.RemoveAt(StartFrameIndex);
}

每次回滚+前滚都是在一个逻辑帧内完成的,玩家并不知道回滚+前滚这个操作的存在,唯一能感受到的就是客户端和服务器误差较大的情况下出现的拉扯或者瞬移。每次回滚之后,回滚的逻辑帧到当前逻辑帧之间的所有快照都将失效,在前滚的过程中需要重新更新快照数据。

  • 序列化与反序列化

服务器到客户端的Snapshot数据传输实际上是通过序列化和反序列化来完成的,服务器上生成的Snapshot通过序列化成二进制数据流,再通过RPC发送到客户端,客户端通过反序列化从二进制数据流中恢复出Snapshot,然后再通过Snapshot进行回滚。

在UE4中,序列化可以通过FArchive来完成,需要注意的是序列化的顺序必须和反序列化的顺序严格相反。对于自定义类型,我们还必须要重载<< operator来实现数据输入和输出。FArchive一共有两种状态Saving和Loading,Saving表示将数据序列化写入到FArchive中,而Loading表示从FArchive中读取数据出来,<< operator会根据当前FArchive所处的状态来执行是读还是写。

下面给一个序列化代码的简单例子:

struct FSerializedData
{
    // 速度
    float Velocity;

    // 位置
    FVector Position;
}

FORCEINLINE FArchive& operator<< (FArchive& Ar, FSerializedData& InSerializedData)
{
    Ar << Velocity;
    Ar << Position;
}

对于服务器和客户端来说,跑的逻辑是一样的,序列化的代码也是一样的,只不过序列化的时候走的分支是不同的。下面给出UECSMoveComponent序列化的代码:

void UECSMoveComponent::Serialize(FArchive& Ar)
{
    if(Ar.IsSaving())
    {
        // 将数据序列化成二进制流
        FSerializedData SerializedMovementData;
        SerializedMovementData.Velocity = Velocity;
        SerializedMovementData.Position = Position;
        
        Ar << SerializedMovementData;
        
       /* 
       *  后续还可以继续序列化其他的数据比如Component中blueprint中的变量,做法也是一样的 
       */
    }
    else if (Ar.IsLoading())
    {
        // 从二进制流中恢复出数据
        FSerializedData SerializedMovementData;
        Ar << SerializedMovementData;
        
        // 恢复出速度和位置数据
        Velocity = SerializedMovementData.Velocity;
        Position = SerializedMovementData.Position;

       /* 
       *  同理,后续可以继续恢复其他数据,比如blueprint中的变量。
       */
    }
}

可以看到反序列化过程和回滚过程有点类似,但是它们实际上是属于不同的流程,回滚之前我们必须先准备好Snapshot数据,而这个Snapshot数据就是客户端在反序列化过程中创建的,经过层层的反序列化最终最上层返回时,整个Snapshot就创建好了,然后客户端进入到后续的回滚逻辑中。

我们可以用代码简单的描述这个过程,首先服务器上会对每一个逻辑帧进行处理,然后进入同步处理阶段。

/*
*  ULogicCmdDispatcher负责命令帧的处理,参数Entity为当前命令帧对应的Entity,该逻辑只会在Server上运行。
*/ 
void ULogicCmdDispatcher::ProcessSnapshot(UECSEntity* Entity)
{
    // Entity可能有很多个Component要序列化,这里我们只关注刚刚提到的MovementComponent
    FArchive Ar;
    UECSMoveComponent* MoveComponent = Entity->Get<UECSMoveComponent>();
    check(MoveComponent);
    
    // 服务器首先将当前Entity的状态序列化成二进制流
    Entity->Serialize(Ar);   

    // 调用客户端的逻辑来处理二进制流,这是一个RPC调用。
    Entity->RestoreSnapshot(Ar);
}

// Run On Owning Client
void ULogicCmdDispatcher::RestoreSnapshot(FArchive& Ar)
{
    UECSMoveComponent* MoveComponent = Entity->Get<UECSMoveComponent>();
    check(MoveComponent);

    UECSEntity* EntitySnapshot = NewObject<UECSEntity>();
    EntitySnapshot->Serialize(Ar);

    // 在这里处理回滚逻辑
    RollBack(EntitySnapshot);
}

服务器到客户端的二进制数据传输通过RPC来完成,所以RPC函数需要标记为Run On Owning Client,表示只在拥有该Entity的客户端上执行该逻辑:

// 服务器上调用,该逻辑只在拥有该Entity的客户端上执行。
UFUNCTION(Client)
void RestoreSnapshot(FArchive& Ar);

该RPC不需要标记为Reliable,因为要确保服务器下发的snapshot及时到达客户端,所以尽量不能有额外的延迟和流量产生,那么这里马上会有另外一个问题出现,就是如果RPC调用丢失了怎么办。其实在前面我们说过为什么不用TCP而要用UDP的原因就是为了降低发包的延迟,有包尽量立即发送出去,就算中途丢包了,导致服务器和客户端不同步,下一帧继续回滚就是了,如果下一帧也丢了那就让下下帧去回滚,反正每一帧服务器都会在有必要的情况让客户端去回滚来保持一致性。

  • 总结

状态同步游戏的开发相比于帧同步游戏要更加困难,后期维护成本也较高,所以一个设计良好,耦合程度低的框架对于网络同步的开发是很有帮助的。可以看到,ECS框架下的回滚逻辑是比较清晰的,对于每一个Local Entity, 我们只需要针对不同的Component编写其回滚的逻辑即可,我们甚至不用关心这个Component的功能是什么,我们只需要保证每一个数据能够正确的恢复出来就行了。

猜你喜欢

转载自blog.csdn.net/paradox_1_0/article/details/107448973