引言
书接上回,在随笔系列的第一篇,我介绍了移动输入和物理模拟的大致过程。第一篇的重点是展示以上过程中,Unreal使用的数学,物理知识。
回顾下第一篇的重点内容,主要是以下三点:
- 客户端收集用户输入后,通过一系列向量处理,转化为用户加速度。
- 单次物理模拟过程(时长为Tick delta time),可以认为是匀加速直线运动。
- Delta time会被拆分为更小的时间间隔,每个间隔内,都会计算当前速度,判断移动的base,变化的距离, 以及角色和环境的碰撞。并最终改变角色的位置,实现角色移动。
第一篇也罗列了实现移动的主要步骤。以下步骤的场景是这样:使用了DS(Dedicated Server);有两个客户端,X和Y登录了游戏;并且客户端X,控制了角色A。
以下是角色A的移动实现逻辑:
- 客户端X收集玩家输入。
- 客户端X对主控角色A(ROLE_AutonomousProxy,1P,第一人称视角)进行物理移动模拟。
- 客户端X将模拟结果, 通过RPC上报DS。
- DS进行对权威角色A(ROLE_Authority,DS上的角色对象)进行物理移动模拟。
- DS通过RPC,响应客户端X上角色A的移动,或者通过RPC修正客户端错误。
- DS将权威角色A的位置信息通过属性同步的方式,通知其他客户端。
- 客户端响应移动同步信息。
- 客户端X响应DS正确移动的RPC回包;或者响应修正的回包,调整角色A位置。
- 客户端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实现移动的物理模拟时,首先会判断客户端上报的时间戳是否合法。
- 首先检查时间戳是否大于服务器记录的上次处理的时间戳。
- 判断客户端运行时间相比服务器本地的运行时间,是否超过指定阈值(比如客户端开了加速器)。超过的话,服务器则会启动强制位置校验,直到客户端的时间戳回归正常范围。
具体的处理层级如下:
代码语言: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处理角色移动的逻辑和客户端类似。不同之处主要是两点:
- 由移动RPC驱动,不需要单独计算加速度。
- 相比客户端的逻辑,增加的错误检查逻辑。
整体的调用层级如下:
代码语言: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篇。