Unreal随笔系列3: 移动逻辑

2023-03-12 10:22:10 浏览数 (4)

引言

书接上回,在随笔系列的第一篇,我介绍了移动输入和物理模拟的大致过程。第一篇的重点是展示以上过程中,Unreal使用的数学,物理知识。

回顾下第一篇的重点内容,主要是以下三点:

  1. 客户端收集用户输入后,通过一系列向量处理,转化为用户加速度。
  2. 单次物理模拟过程(时长为Tick delta time),可以认为是匀加速直线运动。
  3. Delta time会被拆分为更小的时间间隔,每个间隔内,都会计算当前速度,判断移动的base,变化的距离, 以及角色和环境的碰撞。并最终改变角色的位置,实现角色移动。

第一篇也罗列了实现移动的主要步骤。以下步骤的场景是这样:使用了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移动表现。

在这篇文章中,继续探索更多移动实现的细节。

一 对时

使用DS后,角色移动要保证时间的一致性。看到对时这个标题,请不要和修改本地时间划等号。移动同步中的对时逻辑,使用开始移动后的游戏运行时间作为时间戳。

为了了解对时的原理,我们需要梳理下对时依赖的数据结构。首先看下客户端上报DS移动时,最终使用的数据结构:

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

void FCharacterNetworkMoveData::ClientFillNetworkMoveData(const FSavedMove_Character& 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& NewAccel, class FNetworkPredictionData_Client_Character & 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 & CharacterOwner, class UCharacterMovementComponent & 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, &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以来的相对时间,移动时间戳和此类似,记录的是首次移动同步以来的相对时间。

二 移动物理模拟

有了上面内容的基础,后续的移动物理模拟过程的理解就变得简单了,可以参考下面的实现时序:

代码语言:javascript复制
UCharacterMovementComponent::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 // 二次模拟

    UCharacterMovementComponent::PerformMovement
        StartNewPhysics(DeltaSeconds, 0);
            PhysWalking
                MoveAlongFloor
                    ComputeGroundMovementDelta
                    SafeMoveUpdatedComponent
                        MoveUpdatedComponent
                        ResolvePenetration  // 解决穿透
                    if need: SlideAlongSurface

    FSavedMove_Character::PostUpdate

之前的介绍基本已经涵盖了这里的内容,这里附加一点说明。如果引擎检测到碰撞,可能按需进行SlideAlongSurface的操作。 就是我们常见的,角色怼在墙上,但又和墙有一定夹角,角色沿墙滑动的情况。

中间的Pending移动的合并,在后续内容继续介绍。

三 移动上报

移动上报的调用层级如下,主要逻辑位于CallServerMovePacked函数。

代码语言:javascript复制
ControlledCharacterMove
    ReplicateMoveToServer
        PerformMovement
        CallServerMovePacked
            FCharacterNetworkMoveData::ClientFillNetworkMoveData //  FSavedMove_Character-->FCharacterNetworkMoveData

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

移动上报的逻辑,就是将已经完成物理模拟的移动结果,填充到FCharacterNetworkMoveData,然后发送给DS。

虽然物理模拟过程中用到的数据很多,但需要同步给DS的只有如下这些。服务器记录了角色上次的位置,旋转,加速度等信息,所以本次上传只需要上传本次移动的结果即可;CompressedMoveFlags比较关键,包含了移动的具体状态,比如是否是蹲,爬……;MovementBase是角色移动的场景base,比如大地,电梯;MovementMode则是walking, swimming,falling……。

代码语言: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;
};

这里的移动上报频率,会受到一系列配置项的影响,主要的配置项是,ClientNetSendMoveDeltaTime。一般来说客户端的帧率会高于DS,所以RPC请求在客户端并不需要每帧都进行上报。如果在单帧未进行上报,则会将当前构造的FSavedMove_Character缓存在PendingMove中。

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

四 DS的移动处理

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

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

整体的调用层级如下:

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

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

DS主要通过ServerCheckClientError来检测是否发生了某种错误。 按照状态同步,如果ds和客户端的起始状态一致,计算方法一致,那么最终状态也应该一致。如果时间戳检测没有问题,DS会判断下ServerExceedsAllowablePositionError,客户端的最终位置有没有超过指定的阈值。

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

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

五 DS响应客户端

DS响应客户端的调用层级如下:

代码语言:javascript复制
SendClientAdjustment
    ServerData->PendingAdjustment.bAckGoodMove 
        ServerSendMoveResponse(ServerData->PendingAdjustment);
    !ServerData->PendingAdjustment.bAckGoodMove 
        ServerSendMoveResponse
            MoveResponsePacked_ServerSend
                Character::ClientMoveResponsePacked

下发逻辑比较单纯,不需要特别展开,读者如果对细节感兴趣,可以自行阅读相关代码。

六 结语

本篇随笔进一步展示了移动实现的细节,基本涵盖了移动物理模拟到移动同步的全过程,还差客户端响应DS回包的步骤。后续打算再通过一篇文章,补充这部分内容。

随笔系列说明

23年新挖一个《Unreal随笔系列》的坑。所谓随笔就是研究过程中的一些想法随时记录;细节可能来不及考证,甚至一些想法可能也不太成熟,有失偏颇;希望读者也可以帮忙指正和讨论。这个系列主要求量,希望每个月给自己布置一些研究小课题,争取今年发满12篇。

1 人点赞