UE 利用服务器倒带法降低延迟对游戏的影响

滞后补偿

解决客户端由于网络问题,而导致游戏显示或者响应问题,比如当在一个网络延迟下,如果所有状态判断以及实现都是放在服务器上执行,那么当客户端按下移动键,然后需要将该事件传递到服务器上,此时经过一个时间,然后服务器处理结果,再返回给客户端总过经过一个RTT,所以如果RTT过大,将导致按键后,需要很长一段时间才会显示在客户端上。
而如果只是在本地客户端进行预先移动,那么当服务器返回权威位置后,可能会导致Actor的回退

以角色移动的位置为例,一般采用的方法有插值和外推法,用于其他客户端显示本地客户端的角色动画移动,比如说如果本地ping过高,CharacterMovement组件就会在服务器上通过之前的方向和速度,推断本地Actor的位置然后显示到其他客户端,如果之后与实际的位置不同,会利用插值法进行位置修正。如果偏差非常大,则会直接重置Actor的位置,出现瞬移

服务器倒带法

即使存在滞后补偿,但其始终会被限制在一个时间阈值之内才有效,所以为了更准确的判断攻击命中,可以采用服务器倒带法
通过在服务器上对Actor位置信息的存储,当本地客户端进行攻击,会将攻击信息(攻击时间,攻击点)传入到服务器,服务器会返回存储数组中对应的时间点,判断该攻击点是否成功命中,如果成功命中,那么服务器会更新角色位置和处理命中事件。当然这个也是需要限制在一定的Ping下,否则会对于其他玩家,产生不好的效果(比如说躲在掩体后,突然死亡)

检测Ping

由于使用服务器倒带也需要限制Ping,所以可以创建一个UI来提醒自己的Ping是否超过阈值,控制UI显示可以在PlayerController中设置函数调用

void HighPing();
void StopHighPing();

调用方法

调用时机就每隔一段时间检测Ping值,然后调用HighPing,再当HighPingUI显示一段时间后调用StopHighPing
可以利用定时器来实现,但是loop不太好确定,所以可以放在tick里面,通过再增加一个成员变量,记录冷却时间

//.h
float HighPingCoolTime = 0.0f;
float ShowCoolTime = 20.0f;

//.cpp
// Tick
{
  HighPingCoolTime += DeltaTime;
  if(HighPingCoolTime >= ShowCoolTime)
  {
    //利用PlayerState 获取ping
    if(PlayerState->GetPing() * 4 > HighPingRange)
    {
      HighPing();
      PingAnimRunningTime = 0.0f;
    }
    HighPingCoolTime = 0.0f;
  }
  //如果开始播放UI动画,再计时调用StopHighPing()
  PingAnimRunningTime += DeltaTime;
  if(PingAnimRunningTime >= PingAnimRange)
  {
    StopHighPing();
  }
}

本地弥补

主要用于特效声音直接再本地播放,而不是向服务器请求再返回,就是在本地播放这些声音特效,然后服务器调用Multi时进行检测

void Fire()
{
  ServerFire(HitTarget);//服务器
  LocalFire(HitTarget); //本地
}

void ServerFire_Impletetion()
{
  MulticastFire();
}

void MulticastFire_Impletetion()
{
  if(Character && Character->IsLocallyControl && !Character->HasAuthority()) return ; //当前执行Fire的客户端
  if(Character->HasAuthority()) return ; //服务器
  LocalFire(HitTarget); //其他客户端上的显示
}

处理随机弹道问题

主要问题在于对于弹道随机的落点客户端和服务端不一致,这是由于计算随机弹道的函数是是服务器和本地客户端都会执行的(为了避免Lag的影响,会在客户端也调用Fire),那么就会导致服务器上计算一个落点,然后客户端上计算一个落点,导致落点不同
这对于Projectile类型的武器来说是不存在的,因为HitTarget是确定的,且ProjectileMovement组件上UE已经实现的网络复制。
而对于利用射线追踪实现射击的武器,就需要在每次射击前,传入本地此次计算的HitTarget,这样就只会存在唯一结果了。
对于像ShotGun这种一次Fire多发弹道的武器,可以使用一个数组来存储每次本地计算得到的落点,然后通过落点计算在本地计算命中结果,然后在服务器上进行判断。相当于需要重写3个函数 LocalFire,ServerFire,MulticastFire主要就是函数参数的不同,之前是一个HitTargets,现在是一个数组了

客户端预测

避免角色移动突然更新权威位置,有点类似于TCP传输中需要发送SYN和ACK包一样,每次Actor的移动会向服务器发送一个移动位置,该移动位置会携带一个标号,并且本地客户端只会有一个这样的包,每次本地更新都会对上一次的包进行覆盖,服务器处理后也需要返回位置和标号,当客户端在接受了服务器返回的包,需要判断标号是否与当前自己的标号相同,如果服务器返回的标号比当前标号小,说明在2*RTT之间,客户端移动了,那么就会丢弃这个权威位置的修改而保持在原位置。知道当客户端和服务器返回的标号相同,实现完全同步,可以清零准备下一次判断

更新弹药

比如说射击后,更新弹药,在服务器上调用ClientRPC,在本地客户端会维护一个Seq来表示当前发送的序号,一次更新就会++Seq,而在ClientRPC中,首先会更新本地的弹药(AmmoShow)为函数传入过来的值(ServerAmmo),然后--Seq表示处理了一次2*RTT的流程,此时大概率本地实际的弹药(AmmoReal)和当前传过来的弹药(AmmoShow)是不同的,现在只需要将刚刚修改的(AmmoShow)再减去Seq就可以了,因为Seq表示在这一段时间中,客户端Fire后服务器还没有处理的请求,就是弹药的差值

void Cost()
{
    Ammo = Clamp(Ammo-1,0,AmmoNum);
    SetHUD();
    if(HasAuthority())
    {
      ClientUpdateAmmo(Ammo);
    }
    else
    {
      ++Seq;
    }
}

void ClientUpdateAmmo(int ServerAmmo)
{
   Ammo = ServerAmmo;
   --Seq;
   Ammo -= Seq;
  SetHUD();
}

对于增加弹药就不需要利用该方法来避免HUD显示的跳动了.

瞄准状态

如果在高延迟下,快速单击右键瞄准,会导致闪烁两次,所以有一种方法就是在本地维护一个是否按键的布尔值,当本地进行瞄准状态切换时,同时对这个值进行切换,而当瞄准状态通过属性复制从服务器返回到客户端后,本地客户端设置瞄准状态,是通过读取该布尔值实现的

服务器上的处理

前面都是解决本地客户端由于高延迟下会出现视觉体验不好的情况的一种解决方法
而为了校验是否真的有效的实现了命中,这通常是在服务器上进行的,需要考虑延迟对于角色位置传输的影响,这是由于对方Actor通过客户端上的预测进行移动,那么通过一个RTT1传递到服务器,而射击者的客户端需要服务器通过一个RTT2才能接受对方Actor的位置,此时对方Actor就多运动了(RTT1+RTT2)这个时间,并且当射击者Fire后,发送到服务器进行校验,又要经过一个RTT2,最终在服务器上进行判断,服务器会判定没有击中,因为这个时刻上击中点没有Actor.
而利用服务器倒带技术,就是在传递命中结果时,同时传入Fire的时间,因为之前实现了服务器与客户端的时间同步,所以服务器在接受命中检测请求时,会回退到命中时间进行判定命中点是否有Actor,一般是射击者的Fire时间t1,传递到服务器上,服务器会根据两个RTT1+RTT2的时间推断出对方Actor的位置,然后进行判断.

实现方法

获取Box信息

首先由于需要实现回退,所以需要存储一段时间内Actor的位置,而Actor位置需要注意到是角色站立和蹲下的情况,为了检测可以使用对每个肢体添加BoxCollision来解决.而这些BoxCollison的位置就是实际服务器倒带检测命中的组件.

lowerarm_lbox = CreateDefaultSubobject<UBoxComponent>(TEXT("Lowerarm_lboxBox")); //BoxCollision
lowerarm_lbox->SetupAttachment(GetMesh(), FName("lowerarm_l")); // 依附到对应骨骼
HitBoxCompMap.Add(FName("lowerarm_l"), lowerarm_lbox);  //加入到Map中用于匹配Name 和 BoxCollision 方便后续添加使用

两个结构体#

首先对于每一帧需要知道的信息就是** 当前时间+所有Box信息 **,特别的可能对于ShotGun这种一次Fire射击到多个角色的还需要添加Box的所属Actor
而Box信息则是指包括Box的Transform,为了快速找到Box对应的Transform使用TMap进行存储

USTRUCT(BlueprintType)
struct FBoxInfomation
{
	GENERATED_BODY()

	UPROPERTY()
		FVector Location;
	UPROPERTY()
		FRotator Rotation;
	//盒子范围
	UPROPERTY()
		FVector BoxExtent;
};

USTRUCT(BlueprintType)
struct  FFramePackage
{
	GENERATED_BODY()

	//需要存储所有BOX包围盒的信息
	UPROPERTY()
		float Time;

	//使用Map 对每一个骨骼对应一个BOX方便查找,这个Map是Name和BoxInfo
	UPROPERTY()
		TMap<FName, FBoxInfomation> HitBoxInfoMap;

	//针对ShotGun,添加一个该Box属于哪一个角色
	UPROPERTY()
		AXCharacter* Character;
};

保存结构体 -- 存储某一帧的Box信息#

即对于FFramePackage的一个保存,通过获取当前服务器时间以及Character上的BoxCollision进行更新存储

void SaveFramePackage(FFramePackage& Package)
{
	XCharacter = XCharacter == nullptr ? Cast<AXCharacter>(GetOwner()) : XCharacter;
	if (XCharacter)
	{
		Package.Character = XCharacter;
		//因为只在服务器上所以直接获取当前世界时间
		Package.Time = GetWorld()->GetTimeSeconds();
		//存储每个Box的信息
		for (auto& HitBoxPair : XCharacter->HitBoxCompMap)
		{
			FBoxInfomation BoxInformation;
			BoxInformation.Location = HitBoxPair.Value->GetComponentLocation();
			BoxInformation.Rotation = HitBoxPair.Value->GetComponentRotation();
			BoxInformation.BoxExtent = HitBoxPair.Value->GetScaledBoxExtent();
			//将信息存到FramePackage的Map中
			Package.HitBoxInfoMap.Add(HitBoxPair.Key, BoxInformation);
		}
	}
}

历史存储#

即对于上一节实现的FFrameStruct需要存储一定的时间范围内所有的数据,方便后续服务器倒带获取位置,相当于存储t0时刻之前5s内的所有信息,而随着时间的流逝,t0过了1帧,就需要将最早的1帧给丢弃掉.
所以可以采用双链表的形式进行存储,限定最长的存储时间,然后通过tick检测当前链表中存储量进行更新

void SaveFramePackage_Tick()
{
	if (XCharacter == nullptr || !XCharacter->HasAuthority()) return;
	//下列获取FramePackage只在服务器上进行
	if (FrameHistory.Num() <= 1)
	{
		FFramePackage ThisFrame;
		SaveFramePackage(ThisFrame);
		FrameHistory.AddHead(ThisFrame);
	}
	else
	{
		float HistoryLength = FrameHistory.GetHead()->GetValue().Time - FrameHistory.GetTail()->GetValue().Time;
		while (HistoryLength > MaxRecordTime)
		{
			FrameHistory.RemoveNode(FrameHistory.GetTail());
			HistoryLength = FrameHistory.GetHead()->GetValue().Time - FrameHistory.GetTail()->GetValue().Time;
		}
		FFramePackage ThisFrame;
		SaveFramePackage(ThisFrame);
		FrameHistory.AddHead(ThisFrame);
		//ShowFramePackage(ThisFrame, FColor::Red);
	}
}

获取HitTime对应的Box位置#

首先是对HitTime有效性的检测:

  1. HitTime < OldestTime : 说明本地延迟过高,那么就会判断此次射击失效
  2. HitTime > YoungestTime : 基本不可能,但如果是这样的话,直接使用最新的节点Box位置进行命中判断就可以了
    当HitTime有效后,一个HitTime不可能恰好对应一个FrameStruct存储的Time,所以需要考虑插值法对HitTime的Box准确位置进行计算.
    利用2个节点,去搜索HitTime前后的两个FrameStruct,然后插值
TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* YoungerNode = History.GetHead();
TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* OlderNode = History.GetHead();
//OlderTime < HitTime < YoungerTime
while (OlderNode->GetValue().Time > HitTime)
{
	if (OlderNode->GetNextNode() == nullptr) break;
	OlderNode = OlderNode->GetNextNode();
	if (OlderNode->GetValue().Time > HitTime)
	{
		YoungerNode = OlderNode;
	}
}
if (OlderNode->GetValue().Time == HitTime)
{
	bShouldInterp = false;
	FrameToCheck = OlderNode->GetValue();
}
//插值计算准确值
if (bShouldInterp)
{
	//Interp between YougerNode and OlderNode
	FrameToCheck = InterpBetweenFrames(OlderNode->GetValue(), YoungerNode->GetValue(), HitTime);
}

插值计算--InterpBetweenFrames#

主要目的就是计算出HitTime下的FrameStruct,时间就是HitTime,主要通过插值求解Box的Transform,插值比例就是根据HitTime,和其前后FrameHistory的Time求解

验证击中结果

上一节实现了在一段时间中Box信息的存储和对于HitTime下Box信息的存储,接下来就是需要在服务器上进行Hit验证了。
当需要验证的时候,就是将当前时间的Box信息,回退到HitTime,然后在1帧内检测完之后,将Box返回到当前时间,为了实现爆头的高额伤害,在判断时可以再返回一个bool值来判断是否击中头部。
这就是使用box的好处,直接校验命中的hit是否是头部的Box。

//结构体作为返回结果 存储是否击中,以及是否爆头 
//传入hittime时候的box,击打的角色,射击起点以及击中点
FServerSideRewindRes ConfirmHit(const FramePackage& package, Character* HitCharacter, FVector_NetQuantize& traceStart, FVector_NetQuantize& HitLocation)

//存储当前时间下的Box信息,当检测完成之后回退
void CacheBoxPosition(Character* HitCharacter, FramePackage& OutFramePackage)

/*** .cpp ***/

void CacheBoxPosition(Character* HitCharacter, FFramePackage& OutFramePackage)
{
	if (HitCharacter == nullptr) return;
	for (auto& HitBoxPair : HitCharacter->HitBoxCompMap)
	{
		if (HitBoxPair.Value != nullptr)
		{
			FBoxInfomation BoxInfo;
			BoxInfo.Location = HitBoxPair.Value->GetComponentLocation();
			BoxInfo.Rotation = HitBoxPair.Value->GetComponentRotation();
			BoxInfo.BoxExtent = HitBoxPair.Value->GetScaledBoxExtent();
			OutFramePackage.HitBoxInfoMap.Add(HitBoxPair.Key, BoxInfo);
		}
	}
}

ConfirmHit函数#

  1. 保存当前Box信息 CacheBoxPosition
  2. 移动当前Box当寻找到的FramePackage处 MoveBoxPoision 与 CacheBoxPosition逻辑相反,从FramePackage里面读取位置传入到HitBoxPair的位置
  3. 关闭角色本身骨骼的碰撞
  4. 首先判断头部Box的是否被击中,所以先开启头部Box的碰撞
  5. 射线检测,以传入TraceStart为起点,终点需要比HitLocation更向头部一点所以检测距离需要增加
  6. 如果击中,那么就可以结束这个函数返回了,在返回之前,需要将Box回退到之前存储的Box信息,然后开启角色的骨骼碰撞,并且关闭Box碰撞
  7. 如果没有击中,那么就需要打开其他Box的碰撞
  8. 对其他Box进行射线检测,返回结果,无论是否击中都需要关闭Box碰撞,Box回退到之前存储的Box信息,然后开启角色的骨骼碰撞

当前流程#

  1. 检测HitTime是否在合理的时间区间 (主函数 返回命中结构的结构体 {爆头,击中})
  2. 再获取HitTime下的FramePackage
  3. 然后执行ConfirmHit函数判断是否击中

执行射击后所表现的效果(造成伤害)

服务器执行计算函数#

void ServerScoreRequest_Implementation(AXCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime, AWeaponParent* DamageCauser)

该函数用来通过执行前述方法校验是否击中敌人,然后通过传入的武器类型(不直接传入伤害,避免被恶意修改),获取武器的Damage从而造成伤害。
通过执行UE给出的ApplyDamage函数执行伤害逻辑

与武器类的联系#

武器类主要就是执行造成伤害的函数,所以说如果当在客户端上调用时,可以调用上述编写的ServerScoreRequest函数,来进行服务器回退验证击打准确性,但是需要注意的是HitTime,因为当我们在本地点击鼠标进行射击的这个时间t0,为了寻找到此时敌人角色在服务器上的权威位置,那么应该将这个时间t0-RTT/2,这样才会得到敌人的角色也处在我们击打的位置

if (XCharacter && XBlasterPlayerController && XCharacter->GetLagCompensationComp() && XCharacter->IsLocallyControlled()) // 校验在客户端上
{
	XCharacter->GetLagCompensationComp()->ServerScoreRequest(
	  HitCharacter,
	  Start,
	  HitTarget,
	  XBlasterPlayerController->GetSeverTime() - XBlasterPlayerController->SingleTripTime,
	  this
	);
}

以上就是基本实现方法

  1. 通过为人物每个骨骼创建BoxCollision
  2. 然后设置一个Lag有时间, 利用双链表,tick存储角色的Box信息。
  3. 当进行射击时,通过传入的HiTime插值寻找到HitTime对应的Box信息
  4. 利用Confirm函数判断是否击中
  5. 处理伤害事件

其余种类武器

对于HitScan可以使用这种,而ShotGun或者Projectile还需要做一些其他的修改

作者:XTG111

出处:https://www.cnblogs.com/XTG111/p/17981050

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   XTG111  阅读(93)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
more_horiz
keyboard_arrow_up light_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示