你的浏览器不支持canvas

让我们携手共创奇迹!

网络同步

Date: Author: LBD

本文章采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。转载请注明来自WindCrazy

这是一篇关于UE中网络同步的文章,可以看做是联网和多人游戏的进阶篇。

网络同步

可以先参考位置同步之移动预测做一个预热

《Exploring in UE4》网络同步原理深入(上)原理分析

《Exploring in UE4》网络同步原理深入(下)原理分析

网络游戏同步算法的理解https://blog.csdn.net/weixin_43679037/article/details/121858644

https://blog.csdn.net/weixin_43679037/article/details/122848197

UE4 RPC中的序列化

大块数据流送

参考:Multiplayer data streaming in Unreal Engine

如果想了解有关该主题的更多信息,请务必查看Glenn Fiedler这篇精彩文章,其中介绍了一种使用不可靠 RPC 的更通用的解决方案!

虚幻引擎提供了不同的工具来发送数据,例如 RPC 和复制变量。正如我们过去所研究过的,它们每个都有不同的用途:

  • 复制变量:这些变量应在服务器中设置,它们将按照定义的复制条件复制到相关客户端。传入的相关连接也将接收复制变量所保持的状态(如果适用)。
  • RPC :根据操作类型, RPC 有三种。它们用于瞬态(transient)数据,并不保存状态。

然而,有时我们只想向客户端发送数据流或“上传”某些内容到服务器。例如,下载或上传保存游戏文件。保存游戏文件有时会变得非常大,包含大量数据,那么我们就应该有一个办法去进行这些数据的流送。

存在的问题

为什么不能简单的使用ClientReceiveData(Data);

Large bunches

使用虚幻引擎的远程过程调用 (RPC) 系统时,大量数据传输可能是一个问题。因为虚幻引擎采用一种机制来限制单个 RPC 中可以发送的数据量。在 RPC 中传递的数据整合成一个束(bunch),如果超出设置的NetMaxConstructedPartialBunchSizeBytes的最大限制,虚幻引擎将不会发送该束。

此限制由函数IsBunchTooLarge强制执行,该函数在发送之前检查束大小是否超出NetMaxConstructedPartialBunchSizeBytes。如果检查失败,则不会发送束并记录一条错误消息:

FPacketIdRange UChannel::SendBunch( FOutBunch* Bunch, bool Merge )
{
	...
	if (!ensureMsgf(!IsBunchTooLarge(Connection, Bunch), TEXT("Attempted to send bunch exceeding max allowed size. BunchSize=%d, MaximumSize=%d Channel: %s"), Bunch->GetNumBytes(), NetMaxConstructedPartialBunchSizeBytes, *Describe()))
	{
		UE_LOG(LogNetPartialBunch, Error, TEXT("Attempted to send bunch exceeding max allowed size. BunchSize=%d, MaximumSize=%d Channel: %s"), Bunch->GetNumBytes(), NetMaxConstructedPartialBunchSizeBytes, *Describe());
		Bunch->SetError();
		return FPacketIdRange(INDEX_NONE);
	}
	...
}

template<typename T>
static const bool IsBunchTooLarge(UNetConnection* Connection, T* Bunch)
{
	return !Connection->IsUnlimitedBunchSizeAllowed() && Bunch != nullptr && Bunch->GetNumBytes() > NetMaxConstructedPartialBunchSizeBytes;
}

或许你会想简单地增加NetMaxConstructedPartialBunchSizeBytes的值以允许发送更大的数据包(通过更改 CVar NetMaxConstructedPartialBunchSizeBytes)。但是,不建议这样做,因为变量初始化为合理的 64KB 值,以确保带宽使用得到控制并在预算之内。

// Fairly large number, and probably a bad idea to even have a bunch this size, but want to be safe for now and not throw out legitimate data
static int32 NetMaxConstructedPartialBunchSizeBytes = 1024 * 64;
static FAutoConsoleVariableRef CVarNetMaxConstructedPartialBunchSizeBytes(NetMaxConstructedPartialBunchSizeBytes);

解决问题:分块数据

通过将数据拆分成n子数组并发送n个RPC,可以确保每个子数组都足够小,以便在设置的NetMaxConstructedPartialBunchSizeBytes最大限制内发送。

但是,使用循环发送许多可靠的 RPC 并不是一个可行的选择,因为它可能会填满可靠缓冲区,而 Unreal 最终会关闭连接,这不是我们想要的。我们可以在UChannel::SendBunch中看到这一点:

const bool bOverflowsReliable = (NumOutRec + OutgoingBunches.Num() >= RELIABLE_BUFFER + Bunch->bClose);
//...
if (Bunch->bReliable && bOverflowsReliable)
{
	//...
	Connection->SendCloseReason(ENetCloseResult::ReliableBufferOverflow);
	//...
	Connection->Close(ENetCloseResult::ReliableBufferOverflow);
	//...
}

上面的代码中NumOutRec保存了传出的可靠未确认数据包的数量。我们可以看到,如果这个数字加上当前传出的数据包数量将超过了定义在RELIABLE_BUFFER的限制(默认为 256),它将断开连接。

那么,为了避免可靠缓冲区过载,我们需要分散数据流。即在发送之前迭代每个子数组并检查可靠缓冲区的状态。

算法

首先我们定义一个结构体,为了模拟较大的数据

USTRUCT(BlueprintType)
struct FMyData
{
	GENERATED_BODY()

	UPROPERTY()
	int Data_INT;
	UPROPERTY()
	TArray<FTransform> Data_Transforms;
};

然后定义RPC函数

UFUNCTION(Server,Reliable)
void ServerAction();//和键盘映射绑定
UFUNCTION(Client,Reliable)
void ClientAction(const TArray<FMyData>& SendData);//客户端接收

UFUNCTION(Client, Reliable)
void ClientNotifyAllDataReceived(int DataSize);

添加变量

UPROPERTY()
class UActorChannel* Channel;//后要获得NumOutRec
TArray<FMyData> ServerData;//服务器准备发送的数据
TArray<FMyData> ClientReceiveData;
int ChunksToSend = 0;//要发送的块的数量
int ChunksSent = 0;//已经发送的块的数量
bool bStartStream = false;//是否开始流送

实现

#define DATANUM 1000
#define MAXCHUNKSIZE 2
void ANetworkTeachingCharacter::BeginPlay()
{
	Super::BeginPlay();

	if (HasAuthority() && !IsLocallyControlled())
	{
		for (int i = 0; i<=DATANUM;++i)
		{
			TArray<FTransform> Transforms;
			for (int j=0; j<=20; ++j)
			{
				Transforms.Add(FTransform());
			}
			ServerData.Add({i,Transforms});
		}
	}
}
void ANetworkTeachingCharacter::ServerAction_Implementation()
{
	UE_LOG(LogTemp, Warning, TEXT("Call server func"))
	if (ServerData.Num() <= 0)
	{
		UE_LOG(LogTemp , Warning, TEXT("ServerData.Num() <= 0"))
	}
	else
	{
		if (ServerData.Num()<DATANUM)
		{
			UE_LOG(LogTemp, Warning, TEXT("Server haven't ready"))
		}
		else
		{
			ChunksToSend = FMath::CeilToInt(ServerData.Num() / (float)MAXCHUNKSIZE);
			ChunksSent = 0;
			bStartStream = true;
		}
	}
}
void ANetworkTeachingCharacter::ClientAction_Implementation(const TArray<FMyData>& SendData)
{
	if (SendData.Num()<=0)
	{
		UE_LOG(LogTemp, Warning, TEXT("Receive NONE"))
	}
	for (auto MyData : SendData)
	{
		UE_LOG(LogTemp, Warning, TEXT("%d"), MyData.Data_INT)
	}
	ClientReceiveData.Append(SendData);
}

void ANetworkTeachingCharacter::ClientNotifyAllDataReceived_Implementation(int DataSize)
{
	UE_LOG(LogTemp, Warning, TEXT("Receive all data"))
	if (ClientReceiveData.Num() == DataSize)
	{
		UE_LOG(LogTemp, Warning, TEXT("Success"))
	}
}
void ANetworkTeachingCharacter::Tick(float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);
	if (HasAuthority())
	{
		// if (ServerData.Num() <= DATANUM && !bStartStream)
		// {
		// 	ServerData.Add({ServerData.Num(), "a"});
		// }

		if (Channel == nullptr && !IsLocallyControlled())
		{
			Channel = GetNetConnection()->FindActorChannelRef(this);
		}
		while (ChunksSent < ChunksToSend && bStartStream && Channel!=nullptr && Channel->NumOutRec < (RELIABLE_BUFFER / 2))//NumOutRec 为当前可靠的Bunch 的数量,所以可靠Bunch 的数量最多256个。
		{
			UE_LOG(LogTemp, Warning, TEXT("Channel->NumOutRec: %d"), Channel->NumOutRec)
			TArray<FMyData> ChunkBuffer;
			ChunkBuffer.Reset();
			const int32 StartIndex = ChunksSent * MAXCHUNKSIZE;//该ChunksSent的起始位置
			const int32 NumElements = FMath::Min(MAXCHUNKSIZE, ServerData.Num() - StartIndex);//该ChunksSent的数据容量

			if (NumElements > 0 && (StartIndex + NumElements - 1) < ServerData.Num())
			{
				ChunkBuffer.Append(ServerData.GetData() + StartIndex, NumElements);

				ClientAction(ChunkBuffer);
				++ChunksSent;
				UE_LOG(LogTemp, Warning, TEXT("Send subdata, this chunksent: %d"), ChunksSent-1)
			}
		}
		if (ChunksSent >= ChunksToSend && bStartStream)
		{
			UE_LOG(LogTemp, Warning, TEXT("Send all data"))
			ClientNotifyAllDataReceived(ServerData.Num());
			//ServerData.Empty();
			bStartStream = false;
			ChunksToSend = 0;
		}
	}
}

我们通过在 while 循环中每刻发送尽可能多的可靠 RPC来随时间分布数据流。为了降低可靠缓冲区饱和的可能性,我们将预算定义为可靠缓冲区大小的一半(RELIABLE_BUFFER / 2)。一旦我们完成数据流传输,我们就会通过发送一个名为ClientNotifyAllDataReceived的额外可靠客户端 RPC 来通知客户端。

注意:将数据流分散到多个刻度,将有助于我们避免可靠缓冲区溢出并导致虚幻关闭连接。

网络优化与监控

/Engine/Binaries/DotNET/NetworkProfiler.exe

网络优化

  1. 选择性同步:某些数据可能并不需要每帧都进行同步,因为它们的变化对于游戏的表现并不是那么关键。这样的数据可以选择在外部同步时进行压缩、差量同步等优化处理,从而减少网络流量。

  2. 事件驱动同步:有些游戏数据只在特定事件发生时才需要同步,而不是每帧都同步。这些事件可以在游戏内部被捕获,并通过外部同步系统传输给其他客户端,以减少不必要的数据传输。

  3. 数据压缩和加密:通过外部同步系统,可以对需要同步的数据进行压缩和加密,减少数据传输的大小,提高数据传输的安全性。

  4. 客户端预测和校正:外部同步系统也可以用于处理客户端预测和校正的逻辑,以减少服务器和客户端之间的通信次数,提高游戏性能。

“外部同步”通常指的是一种策略,即将一些游戏数据在网络同步时从游戏引擎内部的同步系统中分离出来,类似一个接口将优化网络同步功能抽离出来

参考:https://vorixo.github.io/devtricks/network-managers/


对于本文内容有问题或建议的小伙伴,欢迎提出问题