前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >UE网络通信(四)RPC&移动通信

UE网络通信(四)RPC&移动通信

作者头像
JohnYao
发布2023-04-28 15:03:20
1.6K0
发布2023-04-28 15:03:20
举报
文章被收录于专栏:JohnYao的技术分享

序言

距离上一次发表《UE网络通信》系列的文章已经过去了一年多。这段时间,UE5.0在2022年4月发布;UE5.1在2022年11月发布。好在新版本,引擎在同步方面尚未做大的变更;之前立的关于RPC,底层协议的写作flag,还是可以继续进行。

这段时间,本人工作中,陆续接触了一些Unreal移动同步相关的Bug,同时对移动同步的代码做了进一步的研究, 写了一些随笔。本文整理之前随笔的内容,同时增加移动同步的基础RPC相关内容,按如下顺序展开:

  1. 原理篇1: RPC
  2. 原理篇2: 移动同步
  3. 干货篇:介绍Unreal移动同步常见的Bug及定位解决办法。

原理篇1:RPC

RPC(远程过程调用)和属性同步是Unreal网络同步的两大手段。二者的实现都依赖于Unreal类型系统的反射机制。

如果单独讲Unreal的反射实现,都完全可以开一篇新的文章。针对本文内容,读者可以粗略了解Unreal类型系统和反射机制的原理即可:

  1. Unreal以UObject为基类构建了一个一元化的对象系统.
  2. Unreal为每个特定的UObject子类,构建了该子类的UClass,UFunction,UProperty等元信息,我们也称之为反射信息。
  3. 利用反射信息,我们可以知道一个类属性在数据结构中的偏移,进而可以对该属性进行读取和修改。
  4. 利用反射信息,我们可以通过类函数的字符串名称,实现对成员函数的调用。
  5. 基于以上两点,Unreal可以方便的实现属性的编码解码,以及远程过程函数及其参数的序列化和反序列化,并实现远程调用。

有了对反射的理解,我们以移动的RPC为例,介绍下远程过程调用的全过程。对于RPC相对比较熟悉的同学可以自行跳过。

所有的RPC都需要声明为UFUNCTION。在Unreal内部有三种RPC机制,

  1. DS调用, 客户端执行。UFUNCTION中添加client修饰符。命名规则以Client为前缀。
  2. 客户端调用,DS执行。UFUNCTION中添加server修饰符。命名规则以Server为前缀。
  3. DS调用,DS和所有客户端执行。UFUNCTION中添加NetMulticast修饰符。命名规则以Multicast为前缀。

移动的RPC调用显然属于第二种,它的声明如下:

代码语言:javascript
复制
UFUNCTION(unreliable, server, WithValidation)
    void ServerMovePacked(const FCharacterServerMovePackedBits& PackedBits);
  1. unreliable修饰符表明该RPC使用的是非可靠信道,通信包不保序。
  2. WithValidation修饰符表明在执行实际逻辑前,会首先调用ServerMovePacked_Validate,判断合法性。

反射系统会生成ServerMovePacked的函数体,不需要开发者自己实现。

代码语言:javascript
复制
static FName NAME_ACharacter_ServerMovePacked = FName(TEXT("ServerMovePacked"));
    void ACharacter::ServerMovePacked(FCharacterServerMovePackedBits const& PackedBits)
    {
        Character_eventServerMovePacked_Parms Parms;
        Parms.PackedBits=PackedBits;
        ProcessEvent(FindFunctionChecked(NAME_ACharacter_ServerMovePacked),&Parms);
    }

调用该函数,将执行如下流程:

在UNetDriver::ProcessRemoteFunction函数中,在调用InternalProcessRemoteFunction前有如下判断

代码语言:javascript
复制
<...>
        Connection = Actor->GetNetConnection();
        if (Connection)
        {
            InternalProcessRemoteFunction(Actor, SubObject, Connection, Function, Parameters, OutParms, Stack, bIsServer);
        }
        else
        {
            UE_LOG(LogNet, Warning, TEXT("UNetDriver::ProcessRemoteFunction: No owning connection for actor %s. Function %s will not be processed."), *Actor->GetName(), *Function->GetName());
        }
        <...>

Actor及其Owner的对应实现如下:

代码语言:javascript
复制
UNetConnection* AActor::GetNetConnection() const
{
    return Owner ? Owner->GetNetConnection() : nullptr;
}
UNetConnection* APlayerController::GetNetConnection() const
{
    // A controller without a player has no "owner"
    return (Player != NULL) ? NetConnection : NULL;
}

无论哪种RPC的通信,都依赖于连接(UNetConnection)创建的通信信道(UChannel)。而且其自身或者其Owner的GetNetConnection返回应非空。也就是说客户端连接应该对该对象有所有权,一般来说,必须为PlayerController所拥有。引用官方文档的RPC前置条件的原文如下:

代码语言:javascript
复制
1 They must be called from Actors.
2 The Actor must be replicated.
3 If the RPC is being called from server to be executed on a client, only the client who actually owns that Actor will execute the function.
4 If the RPC is being called from client to be executed on the server, the client must own the Actor that the RPC is being called on.
5 Multicast RPCs are an exception:

RPC的后续处理和属性同步类似,都是使用利用反射系统构造的FRepLayout进行编码构造Bunch包,然后通过网络发送给对端。

代码语言:javascript
复制
UNetDriver::ProcessRemoteFunctionForChannelPrivate
    void FRepLayout::SendPropertiesForRPC
        FRepLayout::SerializeProperties_r
            Cmd.Property->NetSerializeItem(Ar, Map, (Data + Cmd).Data))
    Ch->WriteFieldHeaderAndPayload(TempBlockWriter, ClassCache, FieldCache, NetFieldExportGroup, TempWriter);
        Bunch.WriteIntWrapped( FieldCache->FieldNetIndex, MaxFieldNetIndex );
        Bunch.SerializeIntPacked( NumPayloadBits );
        Bunch.SerializeBits( Payload.GetData(), NumPayloadBits );
    HeaderBits = Ch->WriteContentBlockPayload(TargetObj, Bunch, false, TempBlockWriter);
        WriteContentBlockHeader( Obj, Bunch, bHasRepLayout );
        Bunch.SerializeIntPacked( NumPayloadBits );
        Bunch.SerializeBits( Payload.GetData(), Payload.GetNumBits() );

这里仍然有几个小细节,可以强调下:

  1. DS调用在客户端执行的RPC时,如果对象本身没有同步过,则会触发一次属性同步。
  2. 非可靠RPC不会立即发送,会放在队列中统一发送。
  3. 可靠RPC会立即发送;且管理也略微复杂,有发送待确认队列(大小通过RELIABLE_BUFFER定义)和接收排序队列;在待确认队列满和构造编码包失败的时候触发连接的关闭。所以一些高频操作慎用reliable RPC。

原理篇2:移动同步

上面介绍了移动同步依赖的ServerMovePacked RPC接口,这一节我们更加整体的看下移动同步的细节。

移动实现总流程

Unreal移动及其同步的流程如下。该流程假定,使用了DS(Dedicated Server);有两个客户端,X和Y,登录了游戏;并且客户端X,控制了角色A。

以下是角色A移动的实现流程:

  1. 客户端X收集玩家输入。
  2. 客户端X对主控角色A(ROLE_AutonomousProxy,1P,第一人称视角)进行物理移动模拟。
  3. 客户端X将模拟结果, 通过RPC上报DS。
  4. DS进行对权威角色A(ROLE_Authority,DS上的角色对象)进行物理移动模拟。
  5. DS通过RPC,响应客户端X上角色A的移动,或者通过RPC修正客户端错误。
  6. DS将权威角色A的位置信息通过属性同步的方式,通知其他客户端。
  7. 客户端响应移动同步信息。
    1. 客户端X响应DS正确移动的RPC回包;或者响应修正的回包,调整角色A位置。
    2. 客户端Y收到模拟角色A(ROLE_SimulatedProxy,或者3P)的位置属性,做3P移动表现。

步骤1: 移动输入的收集

对于一个Unreal功能,我们可以用精炼的语言概述其功能,但如果要深度掌握,就需要精读其代码,里面有茫茫多的细节。移动的输入也不例外。

移动输入,用精炼的语言表达该功能可以概述为:在PlayerController的Tick函数中,收集设备输入,并通过一系列的矩阵变化转换为一个方向向量。输入本质上是模拟一个“力”,这个力会产生一个加速度供后续的物理模拟使用。

现在来看下细节,首先是收集输入的函数执行层级:

代码语言:javascript
复制
APlayerController::TickActor
    APlayerController::PlayerTick
        APlayerController::TickPlayerInput
            APlayerController::ProcessPlayerInput
                UPlayerInput::ProcessInputStack
                    FInputAxisUnifiedDelegate::Execute
                        <绑定的按键响应函数>
                            APawn::AddMovementInput
                                UPawnMovementComponent::AddInputVector
                                    APawn::Internal_AddMovementInput

在函数调用的顶层栈是APawn::Internal_AddMovementInput, 它的实现很简单.

代码语言:javascript
复制
void APawn::Internal_AddMovementInput(FVector WorldAccel, bool bForce)
{
    if (bForce || !IsMoveInputIgnored())
    {
        ControlInputVector += WorldAccel;
    }
}

WorldAccel是当帧输入转化为的一个向量,可以理解为力或者加速度的方向。ControlInputVector是Pawn的一个成员变量,记录了未被处理的上次输入。 这两个变量的使用向量加法(平行四边形法)进行合并。

关于该函数的输入,WorldAccel,它是由用户输入,结合以下配置,最后再经过矩阵变换(矩阵的构造又利用了三角函数, 三角函数又利用了弧度角度转换,……),最后利用反平方根单位化,计算出来的。

小结下这部分,该步骤的输出是保存在APawn中的ControlInputVector变量。它累积了未被处理的玩家输入,它代表了驱动角色移动的“力”。

步骤2: 本地物理模拟

上一步收集输入,是在PlayerController的Tick中进行。PlayerController的TickGroup是TG_PrePhysics, 而MovmentComponent的TickGroup是TG_PostPhysics。所以理论上每帧都是先执行输入收集,再执行移动的物理模拟。

物理模拟的整体调用堆栈如下(在步骤3会口冲该步骤):

代码语言:javascript
复制
UCharacterMovementComponent::TickComponent
    UPawnMovementComponent::ConsumeInputVector()
    ControlledCharacterMove
        ScaleInputAcceleration
        ReplicateMoveToServer
            PerformMovement
                StartNewPhysics(DeltaSeconds, 0);
                    PhysWalking
                        CalcVelocity
                        MoveAlongFloor
                            ComputeGroundMovementDelta
                            SafeMoveUpdatedComponent
                                MoveUpdatedComponent
                                ResolvePenetration  // 解决穿透
                            if need: SlideAlongSurface

2.1 输入消耗

在玩家开始真正的物理模拟前, 会获取之前缓存在ControlInputVector的输入数据,并将其置0。

代码语言:javascript
复制
{
    LastControlInputVector = ControlInputVector;
    ControlInputVector = FVector::ZeroVector;
    return LastControlInputVector;
}

2.2 加速度计算

将上步骤获取的输入向量传入到ControlledCharacterMove函数, 及后续ScaleInputAcceleration函数。用于计算物理模拟过程中的加速度。

ScaleInputAcceleration的实现也比较简单, 如果输入的向量长度大于1, 则标准化为单位向量(前面一节已经提过); 否则则采用原始值。

代码语言:javascript
复制
FVector UCharacterMovementComponent::ScaleInputAcceleration(const FVector&amp; InputAcceleration) const
{
    return GetMaxAcceleration() * InputAcceleration.GetClampedToMaxSize(1.0f);
}

FORCEINLINE FVector FVector::GetClampedToMaxSize(float MaxSize) const
{
    if (MaxSize < KINDA_SMALL_NUMBER)
    {
        return FVector::ZeroVector;
    }

    const float VSq = SizeSquared();
    if (VSq > FMath::Square(MaxSize))
    {
        const float Scale = MaxSize * FMath::InvSqrt(VSq);
        return FVector(X*Scale, Y*Scale, Z*Scale);

如果在读者的游戏里,角色的移动更为复杂,可以覆写GetMaxAcceleration函数,根据玩家状态得到合理的加速度。

由于每帧的输入的InputAcceleration都是固定的,在原生实现中,GetMaxAcceleration也是固定的,所以得到的加速度也是固定的。所以角色移动的物理模拟,使用的是初级物理的知识:匀加速运动。 特别的,在单帧内,加速度的方向也不变,所以单帧内,未达到速度上限前,可以认为是匀加速直线运动

关于匀加速直线运动,罗列下相关公式:

代码语言:javascript
复制
瞬时速度公式 v=v0+at;
位移公式 x=v0t+½at²;
平均速度 v=x/t=(v0+v)/2
导出公式 v²-v0²=2ax
加速度 a=(v-v0)/t

引擎主要是利用公式1,计算出瞬时速度,进而算出位移。

2.3 物理模拟

在随后的PhysWalking函数中,会将tick对应的delta time分解为更小的时间段。在这个更小的时间段内进行物理模拟。时间分段逻辑如下:

代码语言:javascript
复制
FMath::Min(MaxSimulationTimeStep, RemainingTime * 0.5f);

每次Tick,

  1. 最多模拟MaxSimulationIterations次。
  2. 每次最久模拟MaxSimulationTimeStep秒(大于MaxSimulationIterations次时,剩余时间可能会有超出)。
  3. 每次都会根据上面的公式和当前的加速度,计算瞬时速度(具体见CalcVelocity函数,最终速度会受最大速度的限制)。
  4. 在这个小时间分片内做匀速运动处理,计算当次的位移。(这里有点微积分的味道)
  5. 根据位移算出最终的位置。并利用底层的物理引擎,判断是否产生了碰撞或者overlap(ResolvePenetration函数),如果有可能会产生沿碰撞物的滑动(SlideAlongSurface)。
  6. 通过MoveUpdatedComponent更新角色胶囊体。

当没有用户输入时,角色会受到摩檫力的影响做匀减速运动。过程也是如上。

由此,我们计算出了玩家当帧的移动状态(加速度,位置,朝向等)。这个移动状态会通过RPC上报给服务器。

步骤3:RPC上报

3.1 RPC上报的数据结构

移动上报的逻辑位于上面的移动物理模拟函数PerformMovement之后,主要在CallServerMovePacked中实现的。同时在ReplicateMoveToServer函数中包含了重要的对时逻辑。

以下是在步骤2的基础上,进一步扩充的移动实现的调用层级:

代码语言:javascript
复制
UCharacterMovementComponent::TickComponent
    UPawnMovementComponent::ConsumeInputVector()
    ControlledCharacterMove
        ScaleInputAcceleration
        ReplicateMoveToServer
            FNetworkPredictionData_Client_Character* ClientData = GetPredictionData_Client_Character();
            ClientData->UpdateTimeStampAndDeltaTime
                CurrentTimeStamp += DeltaTime;

            FSavedMove_Character::SetMoveFor
                SetInitialPosition
                TimeStamp = ClientData.CurrentTimeStamp;            

            CanCombile // 加速度夹角小于5度(0加速度,和任意加速度夹角可以认为是90度)
                Combine With Pending // 二次模拟

            PerformMovement

            FSavedMove_Character::PostUpdate    

            CallServerMovePacked
                FCharacterNetworkMoveData::ClientFillNetworkMoveData //  FSavedMove_Character-->FCharacterNetworkMoveData

CallServerMovePacked函数还有一个Non Packed版本,CallServerMove,决定于项目本身的配置。

前一步骤产生的新的移动状态,会赋值给FCharacterNetworkMoveData,最终编码到ServerMovePacked函数的FCharacterServerMovePackedBits类型的参数中,发送给DS。

虽然物理模拟过程中用到的数据很多,但需要同步给DS的只有如下这些。服务器记录了角色上次的位置,旋转,加速度等信息,在网络不丢包的情况下,只需要上传本次移动的结果即可。

代码语言:javascript
复制
struct ENGINE_API FCharacterNetworkMoveData
{
    ENetworkMoveType NetworkMoveType;
    float TimeStamp;
    FVector_NetQuantize10 Acceleration;
    FVector_NetQuantize100 Location;        // Either world location or relative to MovementBase if that is set.
    FRotator ControlRotation;
    uint8 CompressedMoveFlags;

    class UPrimitiveComponent* MovementBase;
    FName MovementBaseBoneName;
    uint8 MovementMode;
};

CompressedMoveFlags比较关键,包含了移动的具体状态,比如是否是蹲,爬……;MovementBase是角色移动的场景base,比如大地,电梯;MovementMode则是walking, swimming,falling……。

TimeStamp是客户端自开局以来的时间戳。针对这个变量,我们需要展开讲讲Unreal移动同步的对时机制。

3.2 对时机制

使用DS后,角色移动要保证时间的一致性。对时并不是修改客户端本地时间。

为了了解对时的原理,我们从上面的数据结构入手。

代码语言:javascript
复制
struct ENGINE_API FCharacterNetworkMoveData
{
    <...>
    float TimeStamp;
    <...>
};

void FCharacterNetworkMoveData::ClientFillNetworkMoveData(const FSavedMove_Character&amp; ClientMove, FCharacterNetworkMoveData::ENetworkMoveType MoveType)
{
    <...>
    TimeStamp = ClientMove.TimeStamp;
    <...>
}

结构体的定义位于,CharacterMovementReplication.h文件中。 上报DS的时间戳,其实就是MovementComponent中保存的FSavedMove_Character结构体记录的TimeStamp。

FCharacterNetworkMoveData是客户端和服务器通信用的结构体,FSavedMove_Character则是客户端保存的未被服务器确认的移动信息。相比于同步的信息,FSavedMove_Character的成员变量更多,结构体也更复杂;但在本小节,我们也仅关注TimeStamp成员变量。

代码语言:javascript
复制
struct ENGINE_API FSavedMove_Character
{
    <...>
    float TimeStamp;
    <...>
};

void FSavedMove_Character::SetMoveFor(ACharacter* Character, float InDeltaTime, FVector const&amp; NewAccel, class FNetworkPredictionData_Client_Character &amp; ClientData)
{
    <...>
    TimeStamp = ClientData.CurrentTimeStamp;
}

可以看到,FSavedMove_Character TimeStamp成员变量,使用新出现的FNetworkPredictionData_Client_Character结构体的CurrentTimeStamp的值。由于客户端的本地移动并没有在DS实现,所以本地的移动相关数据叫做PredictionData;该结构体保存了一次移动的物理模拟中使用的各种数据。FSavedMove_Character在客户端可能会有很多个实例,但FNetworkPredictionData_Client_Character在客户端仅有一个实例。

代码语言:javascript
复制
float FNetworkPredictionData_Client_Character::UpdateTimeStampAndDeltaTime(float DeltaTime, class ACharacter &amp; CharacterOwner, class UCharacterMovementComponent &amp; CharacterMovementComponent)
{
    // Reset TimeStamp regularly to combat float accuracy decreasing over time.
    if( CurrentTimeStamp > CharacterMovementComponent.MinTimeBetweenTimeStampResets )
    {
        UE_LOG(LogNetPlayerMovement, Log, TEXT("Resetting Client's TimeStamp %f"), Current
        <...>
    }

    // Update Current TimeStamp.
    CurrentTimeStamp += DeltaTime;
    <...>
}

通过上面CurrentTimeStamp赋值逻辑可以看到,移动中的对时使用的时间戳,其实是对象首次同步后游戏运行的相对时间,基于引擎Tick循环的DeltaTime进行计时。

这个时间戳,在服务器和客户端并不完全一致。所以DS实现移动的物理模拟时,首先会判断客户端上报的时间戳是否合法。

  1. 首先检查时间戳是否大于服务器记录的上次处理的时间戳。
  2. 判断客户端运行时间相比服务器本地的运行时间,是否超过指定阈值(比如客户端开了加速器)。超过的话,服务器则会启动强制位置校验,直到客户端的时间戳回归正常范围。

具体的处理层级如下:

代码语言:javascript
复制
ServerMove_PerformMovement
    VerifyClientTimeStamp
        IsClientTimeStampValid
        if Valid:
            ProcessClientTimeStampForTimeDiscrepancy  // Discrepancy 差异
                if (...) ServerData.bForceClientUpdate = true

以上就是移动同步的对时机制,服务器客户端分别记录各自移动物理模拟的时间戳,服务器同时会记录客户端上次移动模拟时间戳。通过对比之间的差异,保证移动包的合法性。

上面提到的引擎的计时逻辑是对时的基础,主要在如下函数实现。感兴趣的同学可以自行阅读,如果只对移动同步感兴趣,可以自行跳到后续小节。

代码语言:javascript
复制
void UEngine::UpdateTimeAndHandleMaxTickRate()
{
    <...>
    static double LastRealTime = FPlatformTime::Seconds() - 0.0001;

    <...>
        // Updates logical time to real time, this may be changed by fixed frame rate below
        double CurrentRealTime = FPlatformTime::Seconds();
        FApp::SetCurrentTime(CurrentRealTime);
        <...>
        // Calculate delta time, this is in real time seconds
        float DeltaRealTime = CurrentRealTime - LastRealTime;
    <...>
}

void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
    <...>
    // Update time.
    RealTimeSeconds += DeltaSeconds;

    <...>
}

FORCEINLINE_DEBUGGABLE float UWorld::GetRealTimeSeconds() const
{
    checkSlow(!IsInActualRenderingThread());
    return RealTimeSeconds;
}

引擎会调用操作系统的时钟函数,获取运行时间。FPlatformTime是一个宏, 在Linux平台使用的是,FUnixTime。

代码语言:javascript
复制
static FORCEINLINE double Seconds()
    {
        struct timespec ts;
        clock_gettime(ClockSource, &amp;ts);
        return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
    }

ClockSource会在启动后,测试如下备选项的性能。CLOCK_REALTIME是实时时钟,返回的是Epoch( 00:00:00 UTC on 1 January 1970)以来的时间。CLOCK_MONOTONIC则一般是,机器启动以来的单调时钟。

代码语言:javascript
复制
Clocks[] =
        {
            { CLOCK_REALTIME, "CLOCK_REALTIME", 0 },
            { CLOCK_MONOTONIC, "CLOCK_MONOTONIC", 0 },
            { CLOCK_MONOTONIC_RAW, "CLOCK_MONOTONIC_RAW", 0 },
            { CLOCK_MONOTONIC_COARSE, "CLOCK_MONOTONIC_COARSE", 0 }
        };

FApp会记录这个CurrentTime,UWorld则会根据Delta记录距离首次Tick以来的相对时间,移动时间戳和此类似,记录的是首次移动同步以来的相对时间。

3.3 可靠性保证

由于移动包高频,且只追求最终结果的一致性。所以引擎将移动RPC定义为unreliable。但从上面小节的内容看,在客户端引入了FSavedMove_Character去保证了移动的可靠性,或者说最终状态一致性。

这部分内容,我们在步骤5,DS的回包阶段做进一步的展开。

3.4 发送频率

对于战术类射击游戏来说,一般来说客户端的帧率会高于DS,所以允许RPC请求在客户端分帧进行上报。这里的移动上报频率,会受到一系列配置项的影响,主要的配置项是,ClientNetSendMoveDeltaTime。

如果在单帧未进行上报,则会将当前构造的FSavedMove_Character缓存在PendingMove中。 PendingMove在下一次Tick会考虑和NewMove进行合并,合并的条件也比较苛刻,包括朝向,MoveMode是否变化。如果可以合并,则会从PendingMove的起始时间戳开始,重新进行移动的物理模拟。如果不能合并,则会在一次RPC中,将PendingMove和NewMove都发送给DS。

步骤4:服务器的物理模拟

服务器的物理模拟过程和客户端的物理模拟过程一致。只是输入使用的是上次记录在服务器的结果,以及RPC中的输入信息。

如果输入信息都一致,物理场景和计算方法都一致,那么理论上物理模拟的结果也应该一致。

此过程的整体的调用层级如下:

代码语言:javascript
复制
ServerMove_PerformMovement
    VerifyClientTimeStamp
        IsClientTimeStampValid
        if Valid:
            ProcessClientTimeStampForTimeDiscrepancy  // Discrepancy 差异
                if (...) ServerData.bForceClientUpdate = true
    MoveAutonomous
        PerformMovement
    ServerMoveHandleClientError when NewMove
        ServerData->bForceClientUpdate || ServerCheckClientError
            ServerExceedsAllowablePositionError
        ServerShouldUseAuthoritativePosition

DS处理角色移动的流程和客户端类似。不同之处主要是两点:

  1. 由移动RPC驱动,不需要单独计算加速度。
  2. 相比客户端的逻辑,增加的错误检查逻辑。

在进行服务器的模拟前,会进行时间戳的校验,这部分第二小节专门做了介绍。这里介绍下服务器对错误的处理。

虽然有一致性的假设,但从上述调用层级,可以看到服务器允许部分误差的存在。如果时间戳检测没有问题,DS会调用ServerCheckClientError来检测是否发生了某种错误,该函数又会调用ServerExceedsAllowablePositionError,判断客户端的最终位置有没有超过指定的阈值。

如果没有超过, 进一步的会根据配置,决定是否使用客户端上传的最终位置。

如果超过阈值,则会标记本次请求是一个Bad request,并通知客户端进行调整。

步骤5 & 7.1:服务器回包给1P & 1P处理

上面DS执行移动逻辑后,会把客户端RPC请求标记为Good或者Bad。

并在对象的属性同步前,调用PlayerController的SendClientAdjustment函数,通过如下调用层级发送给客户端。

代码语言:javascript
复制
SendClientAdjustment
    ServerData->PendingAdjustment.bAckGoodMove 
        ServerSendMoveResponse(ServerData->PendingAdjustment);
    !ServerData->PendingAdjustment.bAckGoodMove 
        ServerSendMoveResponse
            MoveResponsePacked_ServerSend
                Character::ClientMoveResponsePacked
                    MoveResponsePacked_ClientReceive
                        ClientHandleMoveResponse
                            if good
                                ClientAckGoodMove_Implementation
                                    ClientData->AckMove
                            if bad
                                ClientAdjustPosition_Implementation
                                    ClientData->AckMove
                                    OnClientCorrectionReceived
                                        ClientData->bUpdatePosition

对于Packed方式,无论是否Good,都直接使用ServerSendMoveResponse将PendingAdjustment打包,然后通过客户端RPC,ClientMoveResponsePacked发送给客户端。对于非Packed方式,需要构造不同的参数,实现略有区别。

为了保证移动同步的最终结果一致性,客户端会将未确认的移动请求保存在FSavedMove_Character数组中。

如果返回的是Good包,处理很简单,就是将该包对应时间戳之前的所有缓存删除即可。

如果是Bad包,则会调用ClientAdjustPosition_Implementation完成位置的调整, 同时也会清除该包对应时间戳之前的所有缓存。并标记bUpdatePosition,在后一tick,根据此标记调整表现(ClientUpdatePositionAfterServerUpdate实现)。此时就会发生我们所谓的拉扯问题。

步骤6 & 7.2:服务器属性同步给3P & 3P移动处理

步骤6属于属性同步的范畴,在前篇文章已经初步介绍,不做赘述。同步的属性并不在UCharacterMovementComponent内,而是Actor内的

代码语言:javascript
复制
struct FRepMovement ReplicatedMovement;

从UE4到UE5,该结构体从EngineTypes.h迁移到了ReplicatedState.h。 并添加了一些新的成员。 但整体逻辑并没有太多变化。在3P客户端内,根据同步过来的位置等移动状态,做表现向的处理。

在没有开启移动预测的客户端,一般的实现逻辑是先更新胶囊体位置,然后做mesh的表现(动画)趋近胶囊体位置;到达胶囊体位置后,如果没有新的同步包,则位置不会再变化。如果开启了移动预测,在未收到服务器新的包前,仍会继续向前移动。

干货篇:Unreal常见移动Bug

网络游戏中,移动同步的常见两类问题是拉扯和卡顿。

1. 拉扯是指玩家位置从位置A拉到新的位置B, 或者从新的位置被拖拽回老的位置。拉扯在比较严重的情况下会表现为瞬移。

2. 卡顿,更多是性能表现向问题。客户端的一帧运行时间超长,导致画面在某一帧停留较久,出现明显的顿挫感。

卡顿和拉扯经常会被相提并论,而且第一视角的拉扯和卡顿都会产生画面的顿挫感,Bug表现类似。所以处理拉扯卡顿问题的首要任务是要分清楚到底是移动拉扯导致还是性能卡顿的影响。

3P(模拟角色,SimulatedProxy)拉扯问题

开发者一般可以较容易的区分3P的拉扯问题和卡顿问题。因为在主控角色的客户端,3P只是画面的一部分,可以通过画面其他部分的表现判断是否产生了卡顿。

本人定位过的3P拉扯问题主要有两个

1. 下行流量满, 导致3P同步不及时。

2. 开启移动预测后,3P在停止后会拉回一小段距离。

大部分3P客户端都会做平滑处理,只有在上次位置信息和当前位置信息差距比较大的时候才会发生所谓的拉扯。而问题1的情况,发生在某些同步相对比较频繁的场景,某些reliable或者优先级较高的包挤占了流量,导致移动信息迟迟没有同步。解决方法,一是提高玩家角色的同步优先级,二是提高带宽。再有就是做好流量监控,对不合理的流量做优化。

二则属于机制性问题。因为移动预测会导致玩家的距离领先于服务器,在服务器的停止包到来前,位移超前部分会被拉回。整体来看,虽然表现层面并不是很明显(几cm),但解决需要优化Unreal移动预测机制。

1P(主控角色,AutonomousProxy)拉扯问题

1P的拉扯发生,一般会有画面的抖动。可以借助性能分析工具和卡顿做下区分。卡顿一般会带来fps的降低,而拉扯不会。

本人定位过的1P拉扯问题主要有如下:

  1. client ds速度不一致。
    1. 实现机制问题, 客户端,ds分别运动。
    2. 逻辑bug。
  2. client上行带宽占满, 导致移动包丢包。
  3. 服务器卡顿导致的拉扯。
  4. 客户端/ds地形不一致
  5. 校时逻辑导致的原地抽搐型拉扯

实现机制问题

Unreal移动同步的核心是1P主导,DS同步模拟。1P主动移动后,会通过RPC把自己的各种中间状态告诉服务器。如果开发者的实现有DS主动驱动角色的逻辑,哪怕所谓的客户端同步实现,但由于两方执行时机的问题,往往会导致卡顿的发生。

ds和客户端的不一致问题

1.2和4都属于此类。1.2简单的讲,就是某些逻辑问题,使客户端和服务器计算出的最大速度不一致。最终导致了,二者计算的位置不一致。4则是构建流程的问题,ds和客户端level本身不一致,客户端没有阻挡的地方,ds产生了阻挡,ds认为客户端的位置不合法,于是发生了拉扯。

上线带宽占满

这个是我们在开发期发生的一个现象。当时stream level集中在一点被加载,客户端于是频繁发送相关level加载的reliable rpc, 致使带宽占满,非reliable的移动包发生丢包。最新包到达ds后,服务器认为客户端还在丢包之前的位置,于是拉回老位置。

ds卡顿

这个也是比较典型的案例。 当ds卡顿时间超过1s,会出现客户端频繁在原地拉扯的现象。

校时逻辑导致的原地抽搐型拉扯

这个偶现过一次,由于没有实锤,最终只能通过代码逻辑试图解释了下bug的成因。游戏客户端的某些时钟异常,可能触发了DS的校时逻辑的错误分支,角色在DS上的运行时间追赶上客户端后才结束本地的抽搐。

总结

以上就是Unreal角色移动实现的一些细节,希望对尝试这块学习和研究的同学有所帮助。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-04-20,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 序言
  • 原理篇1:RPC
  • 原理篇2:移动同步
    • 移动实现总流程
      • 步骤1: 移动输入的收集
        • 步骤2: 本地物理模拟
          • 2.1 输入消耗
            • 2.2 加速度计算
              • 2.3 物理模拟
                • 步骤3:RPC上报
                  • 3.1 RPC上报的数据结构
                    • 3.2 对时机制
                      • 3.3 可靠性保证
                        • 3.4 发送频率
                          • 步骤4:服务器的物理模拟
                            • 步骤5 & 7.1:服务器回包给1P & 1P处理
                              • 步骤6 & 7.2:服务器属性同步给3P & 3P移动处理
                              • 干货篇:Unreal常见移动Bug
                                • 3P(模拟角色,SimulatedProxy)拉扯问题
                                  • 1P(主控角色,AutonomousProxy)拉扯问题
                                    • 实现机制问题
                                      • ds和客户端的不一致问题
                                        • 上线带宽占满
                                          • ds卡顿
                                            • 校时逻辑导致的原地抽搐型拉扯
                                            • 总结
                                            领券
                                            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档