前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Unreal随笔系列3: 移动逻辑

Unreal随笔系列3: 移动逻辑

作者头像
JohnYao
发布2023-03-12 10:22:10
8420
发布2023-03-12 10:22:10
举报
文章被收录于专栏:JohnYao的技术分享

引言

书接上回,在随笔系列的第一篇,我介绍了移动输入和物理模拟的大致过程。第一篇的重点是展示以上过程中,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篇。

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 一 对时
    • 对时使用的时间戳
      • 引擎计时机制
      • 二 移动物理模拟
      • 三 移动上报
      • 四 DS的移动处理
      • 五 DS响应客户端
      • 六 结语
      • 随笔系列说明
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档