UE4移动组件详解(二)——移动同步机制

        第一部分从移动相关架构以及单机情况下移动的处理细节讲起 UE4移动组件详解(一)——移动框架与实现原理
        而第二部分是关于移动组件同步解决方案的描述,里面有诸多细节来让移动的同步表现的更为流畅。关于移动网络同步这一块内容,博主还有一些地方还没有完全梳理清楚,会在之后的时间里慢慢完善。

四.移动同步解决方案


        前面关于移动逻辑的细节处理都是在PerformMovement里面实现的,我们可以把函数PerformMovement当成一个完整的移动处理流程。这个流程无论是在客户端还是在服务器都必须要执行,或者作为一个单机游戏,这一个接口基本上可以满足我们的正常移动了。不过,在网络游戏中,为了让所有的玩家体验一个几乎相同的世界,需要保证一个具有绝对权威的服务器,这个服务器可以修正客户端的不正常移动行为,保证各个客户端的一致性。相关同步的操作都是基于UCharacterMovement组件实现的,所以我们的角色必须要使用这个移动组件。

        移动组件的同步全都是基于RPC不可靠传输的,你会在UCharacterMovement头文件里面看到多个以Server或者Client开头的RPC函数。

        关于移动组件的同步思路,建议选阅读一下官方文档的内容,https://docs.unrealengine.com/latest/CHN/Gameplay/Networking/CharacterMovementComponent/index.html 回头看可能更为清晰一点。现在我们把整个移动细节作为一个接口封装起来,宏观的研究移动组件的同步细节。

        另外,如果还没有完全搞清ROLE_Authority,ROLE_AutonomousProxy,ROLE_SimulatedProxy的概念,请参考 UE4网络同步详解(一)——理解同步规则。这里举个例子,一个服务器上有一个玩家ServerA和一个NPC ServerB,客户端上拥有从服务器复制过来的这个玩家ClientA与NPC ClientB。由于ServerA与ServerB都是在服务器上生成的,所以他们两在服务器上的所有权Role都是ROLE_Authority。ClientA在客户端上由于被玩家控制,他的Role是ROLE_AutonomousProxy。ClientB在客户端是完全通过服务器同步来控制的,他的Role就是ROLE_SimulatedProxy。

4.1 服务器角色正常的移动流程


        第三章节里面的图3-1就是单机或者ListenServer服务器执行的移动流程。作为一个本地控制的角色,他只需要认真的执行正常的移动(PerformMovement)逻辑处理即可,所以ListenServer服务器移动不再赘述。

        但是对于DedicateServer,他的本地没有控制的角色,对移动的处理就有差异了。分为两种情况:

        该角色在客户端是模拟(Simulate)角色,移动完全由服务器同步过去,如各类AI角色。这类移动一般是服务器上行为树主动触发的
该角色在客户端是拥有自治(Autonomous)权利的Character,如玩家控制的主角。这类移动一般是客户端接收玩家输入数据本地模拟后,再通过RPC发给服务器进行模拟的
从下面的代码可以了解到这两种情况的处理(注意注释):

// UCharacterMovementComponent:: TickComponent
// simulate的角色在服务器执行IsLocallyControlled也会返回true
// Allow root motion to move characters that have no controller.
if( CharacterOwner->IsLocallyControlled() || (!CharacterOwner->Controller && bRunPhysicsWithNoController) || (!CharacterOwner->Controller && CharacterOwner->IsPlayingRootMotion()) )
{
    {
        SCOPE_CYCLE_COUNTER(STAT_CharUpdateAcceleration);

        // We need to check the jump state before adjusting input acceleration, to minimize latency
        // and to make sure acceleration respects our potentially new falling state.
        CharacterOwner->CheckJumpInput(DeltaTime);

        // apply input to acceleration
        Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
        AnalogInputModifier = ComputeAnalogInputModifier();
    }

    if (CharacterOwner->Role == ROLE_Authority)
    {
        // 单机或者DedicateServer控制simulate角色移动
        PerformMovement(DeltaTime);
    }
    else if (bIsClient)
    {
        ReplicateMoveToServer(DeltaTime, Acceleration);
    }
}
else if (CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy)
{
    //DedicateServer控制自治客户端角色移动
    // Server ticking for remote client.
    // Between net updates from the client we need to update position if based on another object,
    // otherwise the object will move on intermediate frames and we won't follow it.
    MaybeUpdateBasedMovement(DeltaTime);
    MaybeSaveBaseLocation();

    // Smooth on listen server for local view of remote clients. We may receive updates at a rate different than our own tick rate.
    if (CharacterMovementCVars::NetEnableListenServerSmoothing && !bNetworkSmoothingComplete && IsNetMode(NM_ListenServer))
    {
        SmoothClientPosition(DeltaTime);
    }
}


这两种情况详细的流程我们在下面两个小结分析。

4.2 Autonomous角色


        一个客户端的角色是完全通过服务器同步过来的,他身上的移动组件也一样是被同步过来的,所以游戏一开始客户端的角色与服务器的数据是完全相同的。对于Autonomous角色,大致的实现思路如下:

        客户端通过接收玩家的Input输入,开始进行本地的移动模拟流程,移动前首先创建一个移动预测数据结构FNetworkPredictionData_Client_Character,执行PerformMovement移动,随后保存当前的移动数据(速度,旋转,时间戳以及移动结束后的位置等信息)到前面的FNetworkPredictionData里面的SavedMoves列表里面,并通过RPC将当前的Move数据发送该数据到服务器。然后继续进行TickComponent操作,重复这个流程。

        客户端在发送给服务器RPC消息的同时,本地还会不断的执行移动模拟。SavedMoves列表里面的数据也就越来越多。如果这时候收到了一个ClientAckGoodMove调用,那么表示服务器接收了对应时间戳的客户端移动,客户端就将这个时间戳之前的SavedMoves全部移除。如果客户端收到了ClientAdjustPosition调用,那么表示对应这个时间戳的移动有问题,客户端需要修改成服务器传过来的位置,并重新播放那些还没被确认的SaveMoves列表里面的移动。

图4-1

整个流程如下图所示:

图4-2 Autonomous角色移动流程图

4.2.1 SavedMoves与移动合并

        仔细阅读源码的朋友对上面给出的流程可能并不是很满意,因为除了ServerMove你可能还看到了ServerMoveDual以及ServerMoveOld等函数接口。而且除了SavedMoves列表,还有PendingMove,FreeMove这些移动列表。他们都是做什么的?

        简单来讲,这属于移动带宽优化的一个方式,将没有意义的移动合并,减少消息的发送量。

        当客户端执行完本次移动后,都会把当前的移动数据以一个结构体保存到SavedMove列表,然后会判断当前的这个移动是否可以被延迟发送(CanDelaySendingMove(),默认为true),如果可以就会继续判断当前的客户端网络速度如何。如果当前的速度有一点慢或者上次更新的时间很短,移动组件就会将当前的移动赋值给PendingMove(表示将要执行的移动)并取消本次给服务器消息的发送。

const bool bCanDelayMove = (CharacterMovementCVars::NetEnableMoveCombining != 0) && CanDelaySendingMove(NewMove);

if (bCanDelayMove && ClientData->PendingMove.IsValid() == false)
{
    // Decide whether to hold off on move
    // send moves more frequently in small games where server isn't likely to be saturated
    float NetMoveDelta;
    UPlayer* Player = (PC ? PC->Player : nullptr);
    AGameStateBase const* const GameState = GetWorld()->GetGameState();

    if (Player && (Player->CurrentNetSpeed > 10000) && (GameState != nullptr) && (GameState->PlayerArray.Num() <= 10))
    {
        NetMoveDelta = 0.011f;
    }
    else if (Player && CharacterOwner->GetWorldSettings()->GameNetworkManagerClass) 
    {
        //这里会根据网络管理的配置以及客户端网络速度来决定是否延迟发送
        NetMoveDelta = FMath::Max(0.0222f,2 * GetDefault<AGameNetworkManager>(CharacterOwner->GetWorldSettings()->GameNetworkManagerClass)->MoveRepSize/Player->CurrentNetSpeed);
    }
    else
    {
        NetMoveDelta = 0.011f;
    }

    if ((GetWorld()->TimeSeconds - ClientData->ClientUpdateTime) * CharacterOwner->GetWorldSettings()->GetEffectiveTimeDilation() < NetMoveDelta)
    {
        // Delay sending this move.
        ClientData->PendingMove = NewMove;
        return;
    }
}

        当客户端进去下次Tick的时候,就会判断当前的新的移动是否能与上次保存的PendingMove合并。如果可以,就可以减少一次消息的发送。如果不能合并,那么在本次移动结束后给服务器发送一个两次移动(ServerMoveDual),就是单纯的执行两次ServerMove。

        服务器在受到两次移动的时候对第一次移动不进行任何校验,只对第二个移动进行正常的校验,判断是否是第一次的标准就是ClientPosition是不是FVector(1.f,2.f,3.f)。通过下面的代码就可以了解了

void UCharacterMovementComponent::ServerMoveDual_Implementation(
    float TimeStamp0,
    FVector_NetQuantize10 InAccel0,
    uint8 PendingFlags,
    uint32 View0,
    float TimeStamp,
    FVector_NetQuantize10 InAccel,
    FVector_NetQuantize100 ClientLoc,
    uint8 NewFlags,
    uint8 ClientRoll,
    uint32 View,
    UPrimitiveComponent* ClientMovementBase,
    FName ClientBaseBone,
    uint8 ClientMovementMode)
{
    ServerMove_Implementation(TimeStamp0, InAccel0, FVector(1.f,2.f,3.f), PendingFlags, ClientRoll, View0, ClientMovementBase, ClientBaseBone, ClientMovementMode);
    ServerMove_Implementation(TimeStamp, InAccel, ClientLoc, NewFlags, ClientRoll, View, ClientMovementBase, ClientBaseBone, ClientMovementMode);
}


        其实,UE的思想就是,将所有的移动的关键信息都数据化,这样移动就可以自由的存储和回放。为了节省带宽,提高效率,我们也就可以想出各种办法来减少发送不必要的消息,对于一个没有移动过的玩家,理论上我们甚至都可以不去同步他的移动信息。

图4-3 移动预测及保存的数据结构示意图

4.3 Simulate角色


        首先看一下官方文档对Simulate角色移动的描述:

        对于那些不由人类控制的人物,其动作往往会通过正常的 PerformMovement() 代码在服务器(此时充当了主控者)上进行更新。Actor 的状态,如方位、旋转、速率和其他一些选定的人物特有状态(如跳跃)都会通过正常的复制机制复制到其他机器,因此,它们不必在每一帧都经由网络传送。为了在远程客户端上针对这些人物提供更流畅的视觉呈现,该客户端机器将在每一帧为模拟代理执行一次模拟更新,直到新的数据(由服务器主控)到来。本地客户端查看其他远程人类玩家时也是如此;远程玩家将其更新发送给服务器,后者为该玩家执行一次完整的动作更新,然后定期复制数据给所有其他玩家。
这个更新的作用是根据复制的状态来模拟预期的动作结果,以便在下一次更新前“填补空缺”。所以,客户端并没有在新的位置放置由服务器发送的代理,然后将它们保留到下次更新到来(可能是几个后续帧),而是通过应用速率和移动规则,在每一帧模拟出一次更新。在另一次更新到来时,客户端将重置本地模拟并开始新一次模拟。

        简单来说,Simulate角色的在服务器上的移动就是正常的PerformMovement流程。而在客户端上,该角色的移动分成两个步骤来处理——收到服务器的同步数据时就直接进行设置。在没有收到服务器消息的时候根据上一次服务器传过来的数据(包括速度与旋转等)在本地执行Simulate模拟,等着下一个同步数据到来。Simulate角色采用这样的机制,本质上是为了减小同步带来的开销。下面代码展示了所有Character的同步属性

   

    void ACharacter::GetLifetimeReplicatedProps( TArray< FLifetimeProperty > & OutLifetimeProps ) const
    {
        Super::GetLifetimeReplicatedProps( OutLifetimeProps );
        DOREPLIFETIME_CONDITION( ACharacter, RepRootMotion,COND_SimulatedOnlyNoReplay);
        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedBasedMovement,   COND_SimulatedOnly );
        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedServerLastTransformUpdateTimeStamp, COND_SimulatedOnlyNoReplay);

        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedMovementMode,    COND_SimulatedOnly );
        DOREPLIFETIME_CONDITION( ACharacter, bIsCrouched,           COND_SimulatedOnly );

        // Change the condition of the replicated movement property to not replicate in replays since we handle this specifically via saving this out in external replay data
        DOREPLIFETIME_CHANGE_CONDITION(AActor,ReplicatedMovement,COND_SimulatedOrPhysicsNoReplay);


    }


        ReplicatedMovement记录了当前Character的位置旋转,速度等重要的移动数据,这个成员(包括其他属性)在Simulate或者开启物理模拟的客户端才执行(可以先忽略NoReplay,这个和回放功能有关)。同时,我们可以看到Character大部分的同步属性都是与移动同步有关,而且基本都是SimulatedOnly,这表示这些属性只在模拟客户端才会进行同步。除了ReplicatedMovement属性以外,ReplicatedMovementMode同步了当前的移动模式,ReplicatedBasedMovement同步了角色所站在的Component的相关数据,ReplicatedServerLastTransformUpdateTimeStamp同步了最新的服务器移动更新帧,也就相当于最后一次服务器更新移动的时间(在ACharacter::PreReplication里会将服务器当前的移动数据赋值给ReplicatedServerLastTransformUpdateTimeStamp然后进行同步)。

        了解了这些同步的数据后,我们开始分析其移动流程。流程如下图所示(RootMotion的情况我在上一章节已经描述,这里不再赘述)。其实其基本思路与普通的移动处理相似,只不过是调用SimulateTick去根据当前的速度等条件模拟客户端移动,但是有一点非常重要的差异就是Simulate的角色的胶囊体移动与Mesh移动是分开进行的。这么做的原因是什么呢?我们稍后再解释。

图4-4 Simulate角色移动流程图

        客户端的模拟我们大致了解了流程,那么接收服务器数据并修正是在哪里处理的呢?答案是AActor::OnRep_ReplicatedMovement。客户端在接收到服务器同步的ReplicatedMovement时,会产生回调函数触发SmoothCorrection的执行,从当前客户端的位置平滑的过度到服务器同步的位置。

        前面提到了胶囊体与Mesh的移动是分开处理的,其目的就是提高代理模拟的流畅度。其实在官方文档上有简单的例子,

        比如这种情况,一个 replicated 的状态显示当前的角色在时间为 t=0 的时刻以速度 (100, 0, 0) 移动,那么当时间更新到 t=1 的时候,这个模拟的代理将会在 X 方向移动 100 个单位,然后如果这时候服务端的角色在发送了那个 (100, 0, 0) 的 replcated 信息后立刻不动了,那么这个 replcated 信息则会使到服务端角色的位置和客户端的模拟位置处于不同的点上。

        为了避免这种“突变”情况,UE采用了Mesh网格的平滑操作。胶囊体的移动正常进行,但是其对应的Mesh网格不随胶囊体移动,而要通过SmoothClientPosition处理,在SmoothNetUpdateTime时间内完成移动,这样玩家在视觉上就不会觉得代理角色的位置突变。通过FScopedPreventAttachedComponentMove类可以限制某个组件暂时不跟随父类组件移动。

        对于Smooth平滑,UE定义了下面几种情况,默认我们采用Exponential(指数增长,越远移动越快):

   

    /** Smoothing approach used by network interpolation for Characters. */
    UENUM(BlueprintType)

     enum class ENetworkSmoothingMode : uint8
     {
       /** No smoothing, only change position as network position updates are received. */
       Disabled     UMETA(DisplayName="Disabled"),

       /** Linear interpolation from source to target. */
       Linear           UMETA(DisplayName="Linear"),

       /** Exponential. Faster as you are further from target. */
       Exponential      UMETA(DisplayName="Exponential"),

       /** Special linear interpolation designed specifically for replays. Not intended as a selectable mode in-editor. */
       Replay           UMETA(Hidden, DisplayName="Replay"),
     };


4.4 关于物理托管后的移动


        一般情况下我们是通过移动组件来控制角色的移动,不过如果给玩家角色的胶囊体(一般Mesh也是)勾选了SimulatePhysics,那么角色就会进入物理托管而不受移动组件影响,组件的同步自然也是无效了,常见的应用就是玩家结合布娃娃系统,角色死亡后表现比较自然的摔倒效果。相关代码如下:

// // UCharacterMovementComponent::TickComponent
// We don't update if simulating physics (eg ragdolls).
if (bIsSimulatingPhysics)
{
    // Update camera to ensure client gets updates even when physics move him far away from point where simulation started
    if (CharacterOwner->Role == ROLE_AutonomousProxy && IsNetMode(NM_Client))
    {
        APlayerController* PC = Cast<APlayerController>(CharacterOwner->GetController());
        APlayerCameraManager* PlayerCameraManager = (PC ? PC->PlayerCameraManager : NULL);
        if (PlayerCameraManager != NULL && PlayerCameraManager->bUseClientSideCameraUpdates)
        {
            PlayerCameraManager->bShouldSendClientSideCameraUpdate = true;
        }
    }
    return;
}


        对于开启物理的Character,Simulate的客户端也是采取移动数据靠服务器同步的机制,只不过移动的数据不是服务器PerformMovement算出来的,而是从根组件的物理对象BodyInstance获取的,代码如下。

void AActor::GatherCurrentMovement()
{
    AttachmentReplication.AttachParent = nullptr;

    UPrimitiveComponent* RootPrimComp = Cast<UPrimitiveComponent>(GetRootComponent());
    if (RootPrimComp && RootPrimComp->IsSimulatingPhysics())
    {
        FRigidBodyState RBState;
        RootPrimComp->GetRigidBodyState(RBState);

        ReplicatedMovement.FillFrom(RBState, this);
        ReplicatedMovement.bRepPhysics = true;
    }
}

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值