📦 Iris 网络复制系统技术分析 - 第九部分:NetBlob 系统

🎯 读完你应该能做到什么?
🧠 知道什么时候该用 NetBlob(什么时候属性复制 / RPC 更合适)。
🧵 看懂 Iris 里 NetBlob 的"发送→排队→分片→可靠→接收→分发"链路,遇到问题知道该从哪里查。
🛠️ 能写出一个最小可用的自定义
UNetBlobHandler(并知道它为什么"必须配套")。
9.1 🚚 一句话理解 NetBlob:大件快递 + 急件插队
如果把网络同步想成"快递系统"📦:
属性复制(Replicated Property):像"每天送报纸"🗞️,量小、频繁、系统自动。
RPC:像"打一通电话"☎️,强调"事件发生了",参数通常不大。
NetBlob:像"寄一件大件家具"🛋️ —— 数据体积大、结构特殊、或者你想要更可控的传输方式(分片、可靠、有序、复用序列化)。
再加一个生活类比:购物排队🛒
普通物品:按队列慢慢结账(普通 Attachment 队列)。
急救药 / 生鲜要化了:可以走"绿色通道"插队(Iris 里
ScheduleAsOOB:Out-Of-Band/立即处理队列)。
这一章你会反复看到一个核心思想:NetBlob 是"你自己能控制的包裹",Iris 提供"分拣中心/分片/可靠队列/类型分发"这些基础设施。
9.2 🧭 NetBlob 在 Iris 里的位置:从 Gameplay 到 Packet
先给你一张"从逻辑到网络包"的总流程图(把抽象概念落到路径上):
PLAINTEXT
Gameplay 代码
|
| 1) 生成 Blob(附件/RPC/自定义)
v
FNetBlobManager::QueueNetObjectAttachment / SendRPC
|
| 2) 入队(普通队列 or OOB 急件队列)
v
FNetObjectAttachmentSendQueue::ProcessQueue
|
| 3) 预序列化
| - 能装下:ShrinkWrap(复用序列化结果)
| - 装不下:Split(拆成 PartialNetBlob)
v
FReplicationWriter::QueueNetObjectAttachments
|
| 4) 写入 Packet(可靠/有序由更下层队列保证)
v
网络发送 → 网络接收
|
v
反序列化:CreateNetBlob → Deserialize
|
v
FNetBlobHandlerManager::OnNetBlobReceived
|
v
你的 UNetBlobHandler::OnNetBlobReceived(业务逻辑)🤔 读这章时你可以带着两个问题:
📤 谁负责"把我的 Blob 写进包里"?(发送侧)
📥 谁负责"收到后把 Blob 交给对的人处理"?(接收侧)
9.3 🧱 FNetBlob:最小抽象与关键标志位
9.3.1 你至少要认识的三个东西:CreationInfo、Flags、引用计数
引擎源码摘录(NetBlob.h):
CPP
// Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationSystem/NetBlob/NetBlob.h
/** Flags that can be set at blob creation time. */enum class ENetBlobFlags : uint32
{
None = 0,
/** The blob should be delivered reliably in order with respect to other reliable blobs. Implies Ordered. */
Reliable = 1U << 0U,
/** Used for FRawDataNetBlob derived classes to avoid duplicate serialization when splitting large blob. */
RawDataNetBlob = Reliable << 1U,
/** Used to indicate that this blob have ObjectReferences that might have to be exported. */
HasExports = RawDataNetBlob << 1U,
/** The blob should respect delivery order with respect to other Ordered blobs, including Reliable ones. Unreliable ordered blobs will only be sent once. */
Ordered = HasExports << 1U,
};
struct FNetBlobCreationInfo
{
FNetBlobType Type = InvalidNetBlobType;
ENetBlobFlags Flags = ENetBlobFlags::None;
};
class FNetBlob
{
public:
// ...
bool IsReliable() const { return EnumHasAnyFlags(GetCreationInfo().Flags, ENetBlobFlags::Reliable); }
void AddRef() const { ++RefCount; }
IRISCORE_API void Release() const;
int32 GetRefCount() const { return RefCount; }
// ...
};把它翻译成人话🗣️:
CreationInfo.Type:像"包裹品类编号"🏷️(由 Handler 系统分配,运行时可能变)。CreationInfo.Flags:像"快递服务选项"📮(是否挂号Reliable、是否按顺序Ordered、是否带导出HasExports)。AddRef/Release:这是 Iris 里 Blob 的生命周期控制方式之一(常见TRefCountPtr)。
9.3.2 自定义 Blob 必须实现的核心虚函数
引擎源码摘录(NetBlob.h):
CPP
class FNetBlob
{
public:
/** Serialize the blob to a bitstream. */
virtual void Serialize(FNetSerializationContext& Context) const PURE_VIRTUAL(Serialize,);
/** Deserialize the blob from a bitstream. */
virtual void Deserialize(FNetSerializationContext& Context) PURE_VIRTUAL(Deserialize,);
/**
* Serialize with object reference optimization.
* When the blob is associated with an object, this avoids redundantly writing the RefHandle.
*/
virtual void SerializeWithObject(FNetSerializationContext& Context, FNetRefHandle RefHandle) const;
virtual void DeserializeWithObject(FNetSerializationContext& Context, FNetRefHandle RefHandle);
};关键点🔑:
Serialize/Deserialize:纯虚函数,自定义 Blob 必须实现,负责把业务数据写入/读出 bitstream。SerializeWithObject/DeserializeWithObject:当 Blob 作为某个对象的附件(FNetObjectAttachment)发送时,系统可能调用这对函数。默认实现会调用普通的Serialize/Deserialize,但你可以重写它来避免重复写入已知的 RefHandle(因为附件的目标对象引用已经在上层写过了)。
什么时候用
SerializeWithObject?
当bSerializeWithObject = true时(由PreSerializeAndSplitNetBlob根据附件是否绑定对象决定),系统会调用带 Object 的版本。这是一个优化点:如果你的 Blob 内部需要引用它所属的对象,可以直接用传入的RefHandle,而不是再序列化一遍。
9.3.3 FNetObjectAttachment:很多 NetBlob 其实是"挂在某个对象上的附件"
引擎源码摘录(同文件):
CPP
class FNetObjectAttachment : public FNetBlob
{
public:
const FNetObjectReference& GetNetObjectReference() const { return NetObjectReference; }
const FNetObjectReference& GetTargetObjectReference() const { return TargetObjectReference; }
protected:
friend class Private::FNetBlobManager;
FNetObjectReference NetObjectReference;
FNetObjectReference TargetObjectReference;
};理解方式:
很多时候你并不是"广播一个纯数据块",而是"给某个对象寄一个附件"(例如 RPC、对象初始化块)。
Attachment 的意义在于:发送端/接收端都知道它"属于哪个对象",从而可以走更高效的路径(比如
SerializeWithObject,避免重复写同一个NetRefHandle)。
9.4 🧰 Handler 体系:谁负责"开箱验货"
NetBlob 本体只管"装数据/序列化",业务处理靠 Handler。
9.4.1 UNetBlobHandler:单类型处理器
引擎源码摘录(NetBlobHandler.h):
CPP
UCLASS(transient, MinimalApi, Abstract)
class UNetBlobHandler : public UObject, public INetBlobReceiver
{
GENERATED_BODY()
public:
TRefCountPtr<FNetBlob> CreateNetBlob(UE::Net::ENetBlobFlags Flags) const;
UE::Net::FNetBlobType GetNetBlobType() const { return NetBlobType; }
protected:
IRISCORE_API virtual TRefCountPtr<FNetBlob> CreateNetBlob(const FNetBlobCreationInfo&) const override PURE_VIRTUAL(CreateNetBlob, return nullptr;);
IRISCORE_API virtual void OnNetBlobReceived(UE::Net::FNetSerializationContext& Context, const TRefCountPtr<FNetBlob>&) override PURE_VIRTUAL(OnNetBlobReceived,);
private:
UE::Net::FNetBlobType NetBlobType;
};这段代码告诉你两件很"硬核但很重要"的事实:
每个 Handler 只负责一种 Blob 类型(
NetBlobType)。接收端想处理 Blob,必须先
CreateNetBlob(创建正确的派生类实例),然后Deserialize,最后才能OnNetBlobReceived。
9.4.2 FNetBlobHandlerManager:类型分发器(把包裹交给对应柜台)
引擎源码摘录(NetBlobHandlerManager.cpp):
CPP
bool FNetBlobHandlerManager::RegisterHandler(UNetBlobHandler* Handler){
const FString& ClassName = Handler->GetClass()->GetName();
const UNetBlobHandlerDefinitions* BlobHandlerDefinitions = GetDefault<UNetBlobHandlerDefinitions>();
for (const FNetBlobHandlerDefinition& Definition : MakeArrayView(BlobHandlerDefinitions->NetBlobHandlerDefinitions))
{
if (Definition.ClassName.ToString() == ClassName)
{
const uint32 Index = static_cast<uint32>(&Definition - BlobHandlerDefinitions->NetBlobHandlerDefinitions.GetData());
Handler->NetBlobType = Index;
Handlers[Index] = Handler;
return true;
}
}
return false;
}用"快递柜"来解释🗄️:
UNetBlobHandlerDefinitions是"快递柜格口列表"(允许有哪些 Handler)。RegisterHandler的过程是:你拿着一个快递(Handler)来报到
系统去"格口清单"里找同名柜台
找到了就给你发一个"格口编号"作为
NetBlobType
也就是说:你自定义 Handler,光写 C++ 还不够,还得确保它能被 Definitions 识别,否则 RegisterHandler 会失败(日志里也会提醒)。
9.4.3 NetBlobType 在包里占几位?(以及为什么最多 128 个 Handler)
这部分是写技术博客时非常"加分"的细节,因为它解释了一个看似离谱、但其实很工程的约束:NetBlob 的 Type 在默认实现里只有 7 bit。
9.4.3.1 🔬 FNetBlob::SerializeCreationInfo:只序列化 Type(7 bit)+ Reliable(1 bit)
引擎源码摘录(NetBlob.cpp):
CPP
void FNetBlob::SerializeCreationInfo(FNetSerializationContext& Context, const FNetBlobCreationInfo& CreationInfo){
FNetBitStreamWriter* Writer = Context.GetBitStreamWriter();
Writer->WriteBits((CreationInfo.Type == 0 ? 0U : 1U), 1U);
if (CreationInfo.Type != 0)
{
Writer->WriteBits(CreationInfo.Type, 7U);
}
// Retain Reliable flag
Writer->WriteBits(EnumHasAnyFlags(CreationInfo.Flags, ENetBlobFlags::Reliable) ? 1U : 0U, 1U);
}这意味着✅:
Type ∈ 0..127(最多 128 个类型)
CreationInfo.Flags 默认只保留 Reliable(其它 Flag 是否序列化,取决于具体派生类/协议设计)
9.4.3.2 🔬 FNetBlobHandlerManager::Init:源码直接 checkf(< 128)
引擎源码摘录(NetBlobHandlerManager.cpp):
CPP
// Check if FNetBlob::SerializeCreationInfo needs to use more bits for blob type.checkf(BlobHandlerDefinitions->NetBlobHandlerDefinitions.Num() < 128,
TEXT("Excessive amount of NetBlobHandlers: %d. This breaks net serialization."),
BlobHandlerDefinitions->NetBlobHandlerDefinitions.Num());9.4.3.3 📌 "兼容性规则"⚠️
把上面两段连起来,你就能得出一个很关键的结论:
UNetBlobHandlerDefinitions里的顺序就是 Type 的分配顺序(Index = Type)。因此 客户端/服务端必须保持同一份 Definitions,且顺序一致,否则就会出现"我以为你寄的是 A,你其实寄的是 B"的灾难级错包。
可以用一个生活类比来解释:
你们两个人约定"快递柜 12 号格放药品、13 号格放生鲜"。
如果有一天一方把格子顺序调了,另一方不知情,那就是开箱即事故💥。
9.4.4 示例:一个"聊天消息 Blob"(示例代码,不是引擎源码)💬
下面是"写博客用的示例实现",核心目的是展示:你需要一个 Blob + 一个 Handler,并保证序列化对称。
CPP
// 示例:聊天消息 Blob(建议 Reliable)class FChatMessageBlob : public UE::Net::FNetBlob
{
public:
FString Sender;
FString Msg;
// 构造函数:必须正确初始化基类的 CreationInfo
FChatMessageBlob(const UE::Net::FNetBlobCreationInfo& InCreationInfo)
: FNetBlob(InCreationInfo)
{
}
virtual void Serialize(UE::Net::FNetSerializationContext& Context) const override
{
auto* Writer = Context.GetBitStreamWriter();
// 注意:实际 API 可能是 WriteString 或需要自己写长度+内容
UE::Net::WriteString(Writer, Sender);
UE::Net::WriteString(Writer, Msg);
}
virtual void Deserialize(UE::Net::FNetSerializationContext& Context) override
{
auto* Reader = Context.GetBitStreamReader();
UE::Net::ReadString(Reader, Sender);
UE::Net::ReadString(Reader, Msg);
}
};
UCLASS()
class UChatMessageBlobHandler : public UNetBlobHandler
{
GENERATED_BODY()
virtual TRefCountPtr<UE::Net::FNetBlob> CreateNetBlob(const UE::Net::FNetBlobCreationInfo& Info) const override
{
// 使用传入的 CreationInfo 构造 Blobreturn new FChatMessageBlob(Info);
}
virtual void OnNetBlobReceived(UE::Net::FNetSerializationContext& Context, const TRefCountPtr<UE::Net::FNetBlob>& Blob) override
{
const FChatMessageBlob* Chat = static_cast<const FChatMessageBlob*>(Blob.GetReference());
UE_LOG(LogTemp, Log, TEXT("Chat from %s: %s"), *Chat->Sender, *Chat->Msg);
// TODO:交给 UI/聊天系统
}
};关键点🔑:
Blob 构造函数必须接受
FNetBlobCreationInfo并传给基类,否则Type和Flags会丢失。Serialize/Deserialize必须对称:写什么顺序,读就什么顺序,类型和位数都要一致。Handler 的
CreateNetBlob要用传入的Info,不要自己硬编码Type(Type是运行时由FNetBlobHandlerManager分配的)。
9.5 🏭 FNetBlobManager:调度中心与两条队列
如果说 Handler 是"收货柜台",那 FNetBlobManager 更像"快递分拣中心"🏭。
9.5.1 初始化时会注册默认 Handler(RPC/分片/大对象)
引擎源码摘录(NetBlobManager.cpp):
CPP
void FNetBlobManager::Init(FNetBlobManagerInitParams& InitParams){
BlobHandlerManager.Init();
// ... 绑定 ReplicationSystem / Connections / NetRefHandleManager / ObjectReferenceCache
RegisterDefaultHandlers();
AttachmentSendQueue.Init(this);
}
void FNetBlobManager::RegisterDefaultHandlers(){
RPCHandler = TStrongObjectPtr<UNetRPCHandler>(NewObject<UNetRPCHandler>());
RPCHandler->Init(*ReplicationSystem);
RegisterNetBlobHandler(RPCHandler.Get());
PartialNetObjectAttachmentHandlerConfig = GetDefault<UPartialNetObjectAttachmentHandlerConfig>();
PartialNetObjectAttachmentHandler = TStrongObjectPtr<UPartialNetObjectAttachmentHandler>(NewObject<UPartialNetObjectAttachmentHandler>());
PartialNetObjectAttachmentHandler->Init(InitParams);
RegisterNetBlobHandler(PartialNetObjectAttachmentHandler.Get());
NetObjectBlobHandler = TStrongObjectPtr<UNetObjectBlobHandler>(NewObject<UNetObjectBlobHandler>());
RegisterNetBlobHandler(NetObjectBlobHandler.Get());
}要点📌:
Iris 自己就会确保"常用的那几类"能工作(RPC、分片、NetObjectBlob)。
你写博客时可以强调:很多功能你不需要手动注册,但自定义 Handler 必须双方都注册。
9.5.2 入队:QueueNetObjectAttachment 会帮你处理"目标不是可复制对象"的边界
引擎源码摘录(NetBlobManager.cpp,删减):
CPP
bool FNetBlobManager::QueueNetObjectAttachment(uint32 ConnectionId, const FNetObjectReference& TargetRef, const TRefCountPtr<FNetObjectAttachment>& Attachment, ENetObjectAttachmentSendPolicyFlags SendFlags){
FOwnerInfo OwnerInfo;
OwnerInfo.TargetRef = TargetRef;
// 解析 Root/SubObject 的 internal index
bool bCanSendRpc = GetRootObjectAndSubObjectIndicesFromAnyHandle(TargetRef.GetRefHandle(), OwnerInfo.RootObjectIndex, OwnerInfo.SubObjectIndex);
if (!bCanSendRpc)
{
// 如果 TargetRef 有效但没被复制,尝试用 ReplicatedOuter
OwnerInfo.CallerRef = ObjectReferenceCache->GetReplicatedOuter(TargetRef);
bCanSendRpc = GetRootObjectAndSubObjectIndicesFromAnyHandle(OwnerInfo.CallerRef.GetRefHandle(), OwnerInfo.RootObjectIndex, OwnerInfo.SubObjectIndex);
}
if (!bCanSendRpc)
{
// 仍然失败:目标对象不可复制,无法发送return false;
}
Attachment->SetNetObjectReference(OwnerInfo.CallerRef, OwnerInfo.TargetRef);
AttachmentSendQueue.Enqueue(ConnectionId, OwnerInfo.RootObjectIndex, OwnerInfo.SubObjectIndex, Attachment, SendFlags);
return true;
}FOwnerInfo 结构说明:
CPP
struct FOwnerInfo
{
FNetObjectReference CallerRef; // 实际用于路由的"可复制外层对象"引用
FNetObjectReference TargetRef; // 原始目标对象引用
FInternalNetRefIndex RootObjectIndex; // 根对象的内部索引
FInternalNetRefIndex SubObjectIndex; // 子对象的内部索引(如果有)
};比如你想把一个 RPC/Attachment 发给不可复制的组件/子对象(
TargetRef解析RootObjectIndex/SubObjectIndex失败),Iris 就会用ObjectReferenceCache->GetReplicatedOuter(TargetRef)找到它挂靠的可复制外层对象(例如角色ACharacter)作为CallerRef,再用这个"主门牌"解析出RootObjectIndex/SubObjectIndex,从而把包裹正确投递到"楼栋 + 房间号"。
9.6 📤 发送侧主流程:入队→预序列化→(ShrinkWrap 或 Split)→写包
发送侧最关键的"分岔路口"其实就一句话:
先尝试把 Blob 序列化进一个"阈值大小"的缓冲区,
能写进去:用 ShrinkWrap(后续复用、写包快)
写爆了:Split(变成
FPartialNetBlob序列)
这条路径由 UPartialNetObjectAttachmentHandler 负责(默认 Handler 之一)。
9.6.1 OOB(急件)到底是什么?ScheduleAsOOB 的真实含义 🚑
在 Iris 里,"急件"不是玄学,它是一条单独的队列。
引擎源码摘录(NetBlobManager.cpp,删减):
CPP
const bool bScheduleUsingOOBAttachmentQueue = EnumHasAnyFlags(SendFlags, ENetObjectAttachmentSendPolicyFlags::ScheduleAsOOB);
FQueue& TargetQueue = bScheduleUsingOOBAttachmentQueue ? ScheduleAsOOBAttachmentQueue : AttachmentQueue;可以用"急诊分诊"来类比:
普通队列:按帧处理(慢一点但稳定)
OOB 队列:优先处理、并且会标记连接需要"立即发送"
9.6.1.1 🚨 "立即发送"是怎么触发的?
当附件被标记为 OOB 时,系统会把对应的连接 ID 加入 OutConnectionsPendingImmediateSend 集合:
CPP
// NetBlobManager.cpp (ProcessQueue 内部)if (bScheduleUsingOOBAttachmentQueue)
{
OutConnectionsPendingImmediateSend.Add(ConnectionId);
}这个集合会在 FReplicationSystem::SendAndFlushPackets 时被检查:
普通附件:等到下一次
Tick或SendUpdate时才会被写入包。OOB 附件:会触发当前帧内的额外发包(
FlushNet),尽可能快地把数据送出去。
注意:"立即发送"不是"绕过网络延迟",而是"不等下一帧,现在就尝试发包"。如果当前帧已经发过包,OOB 会触发额外的发包调用。
写博客时建议配一个小对比表:
队列 | 生活类比 | 触发方式 | 发包时机 | 适合场景 |
|---|---|---|---|---|
普通 Attachment 队列 | 普通排队结账 🛒 | 默认 | 下一次 Tick/SendUpdate | 常规 RPC/附件 |
OOB 队列 | 急诊绿色通道 🚑 |
| 当前帧立即尝试 | 需要尽快送达的附件 |
9.6.2 多播 + 连接特化序列化:为什么"有时不能共享 ShrinkWrap"?
你在 ProcessQueue 会看到一个非常关键的条件分支:
多播(Multicast)时,理想情况是"预序列化一次,发送给所有连接"。
但如果这个 Blob 的
ReplicationStateDescriptor标记了HasConnectionSpecificSerialization,那就意味着:同一份 Blob,对不同连接序列化出来的结果可能不同
这时就不能共享同一个 ShrinkWrap/同一组 Partial 结果
引擎源码摘录(NetBlobManager.cpp,删减):
CPP
const bool bMulticast = Entry.ConnectionId == 0;
const bool bHasConnectionSpecificSerialization = ReplicationStateDescriptor && EnumHasAnyFlags(ReplicationStateDescriptor->Traits, EReplicationStateTraits::HasConnectionSpecificSerialization);
if (!(bMulticast && bHasConnectionSpecificSerialization) && !PreSerializeAndSplitNetBlob(Entry.ConnectionId, Attachment, PartialNetBlobs, bShouldSendAttachmentsWithObject))
{
// ...
}
if (bMulticast)
{
for (uint32 ConnectionId : MakeArrayView(ProcessContext.ConnectionIds))
{
if (bHasConnectionSpecificSerialization)
{
PartialNetBlobs.Reset();
PreSerializeAndSplitNetBlob(ConnectionId, Attachment, PartialNetBlobs, bShouldSendAttachmentsWithObject);
AttachmentsView = MakeArrayView(PartialNetBlobs);
}
Connection->ReplicationWriter->QueueNetObjectAttachments(..., AttachmentsView, ...);
}
}一句话总结🧾:
能共享时就共享(ShrikWrap 的价值最大化)。
不能共享时就按连接重做(保证正确性)。
9.7 🎁 预序列化与 ShrinkWrap:一次打包,多人复用
9.7.1 UPartialNetObjectAttachmentHandlerConfig:阈值不是"拍脑袋"
引擎源码摘录(PartialNetObjectAttachmentHandler.h):
CPP
class UPartialNetObjectAttachmentHandlerConfig : public USequentialPartialNetBlobHandlerConfig
{
public:
uint32 GetBitCountSplitThreshold() const { return BitCountSplitThreshold; }
uint32 GetClientUnreliableBitCountSplitThreshold() const { return ClientUnreliableBitCountSplitThreshold; }
uint32 GetServerUnreliableBitCountSplitThreshold() const { return ServerUnreliableBitCountSplitThreshold; }
private:
UPROPERTY(Config)
uint32 BitCountSplitThreshold = (128 + 64)*8;
UPROPERTY(Config)
uint32 ClientUnreliableBitCountSplitThreshold = (850)*8;
UPROPERTY(Config)
uint32 ServerUnreliableBitCountSplitThreshold = (256)*8;
};这三行默认值背后的直觉:
分片有额外开销(序列号、首包信息、更多包头),所以"并不是越容易分片越好"。
不可靠数据在不同端的策略不同:客户端/服务端的阈值分别配置。
9.7.2 关键实现:溢出才 Split,否则 ShrinkWrap
引擎源码摘录(PartialNetObjectAttachmentHandler.cpp,删减):
CPP
bool UPartialNetObjectAttachmentHandler::PreSerializeAndSplitNetBlob(uint32 ConnectionId, const TRefCountPtr<UE::Net::FNetObjectAttachment>& Blob, TArray<TRefCountPtr<FNetBlob>>& OutPartialBlobs, bool bSerializeWithObject){
uint32 BitCountSplitThreshold = GetConfig()->GetBitCountSplitThreshold() & ~31U;
const bool bIsReliable = EnumHasAnyFlags(Blob->GetCreationInfo().Flags, ENetBlobFlags::Reliable);
if (!bIsReliable)
{
BitCountSplitThreshold = (ReplicationSystem->IsServer() ? GetConfig()->GetServerUnreliableBitCountSplitThreshold() : GetConfig()->GetClientUnreliableBitCountSplitThreshold()) & ~31U;
}
TArray<uint32> Payload;
Payload.AddUninitialized(BitCountSplitThreshold/32U);
FNetBitStreamWriter Writer;
Writer.InitBytes(Payload.GetData(), Payload.Num()*4U);
// ... 设置 SerializationContext(含 LocalConnectionId)并序列化 Blob
Writer.CommitWrites();
if (Writer.IsOverflown())
{
return Super::SplitNetBlob(SerializationContext, /*...*/, OutPartialBlobs);
}
else
{
FShrinkWrapNetObjectAttachment* ShrinkWrap = new FShrinkWrapNetObjectAttachment(SerializationContext, Blob, MoveTemp(Payload), Writer.GetPosBits());
OutPartialBlobs.AddDefaulted_GetRef() = ShrinkWrap;
return true;
}
}用"打包发货"来解释:
先准备一个"预计能装下的箱子"📦(阈值大小)。
装得下:直接把这个箱子封好贴条(ShrinkWrap),后面发给多少人都是"搬同一个箱子"。
装不下:再去做"拆箱分件"(Split)。
这也是为什么
ShrinkWrapNetBlob.h的注释会强调:它是为了"多目的地复用序列化结果"。
9.7.3 ShrinkWrap 的"接收端视角":它根本不会被 Deserialize ✅
很多同学第一次看到 FShrinkWrapNetBlob 会以为"接收端也要解开包装"。其实 Iris 的设计更像现实世界的快递:
ShrinkWrap 的本质是:发送端把原始 Blob 的序列化结果缓存下来,后续直接把"缓存的 bitstream"写进包里。
对接收端来说:它收到的依然是"原始 Blob 类型的 CreationInfo + 原始 Blob 的 payload"。
所以你在源码里会看到非常"强硬"的断言:
引擎源码摘录(ShrinkWrapNetBlob.cpp):
CPP
void FShrinkWrapNetBlob::Deserialize(FNetSerializationContext& Context){
checkf(false, TEXT("%s"), TEXT("This function should not be called. Contact the networking team."));
}9.7.3.1 ❓ 为什么它还能正确处理 Exports?
ShrinkWrap 还顺手解决了一个常被忽略的问题:导出(Exports)不能丢。
引擎源码摘录(ShrinkWrapNetBlob.cpp,构造函数):
CPP
NetTokenExportsArray = Context.GetExportContext()->GetBatchExports().NetTokensPendingExportInCurrentBatch;
CreationInfo.Flags |= NetTokenExportsArray.Num() ? ENetBlobFlags::HasExports : ENetBlobFlags::None;对于 FShrinkWrapNetObjectAttachment,它还会把目标对象的引用加入导出(避免目标引用在接收端解析不到):
CPP
if (Private::FNetExportContext* ExportContext = Context.GetExportContext())
{
const Private::FObjectReferenceCache* ObjectReferenceCache = Context.GetInternalContext()->ObjectReferenceCache;
ObjectReferenceCache->AddPendingExport(*ExportContext, OriginalBlob->GetNetObjectReference());
ObjectReferenceCache->AddPendingExport(*ExportContext, OriginalBlob->GetTargetObjectReference());
}ShrinkWrap 不只是"缓存序列化",它还会把"这份缓存里依赖的外部引用/Token"一起打包好,避免接收端出现"数据到了,但引用没到"的半残状态。
9.8 🧩 真正的分片:FPartialNetBlob 的序列号与首包信息
9.8.1 为什么需要全局序列号?(避免串包)
引擎源码摘录(PartialNetBlob.cpp 注释):
CPP
// We need a unique sequence number per split blob to detect out of order sequences.// ... sharing a global sequence number avoids bloating the splitting API ...static std::atomic<uint32> PartialNetBlobGlobalSequenceNumber;人话🗣️:
分片是"一个大包裹拆成很多小包裹"。
网络上可能乱序/丢包/重传,接收端需要能判断"这些碎片是不是同一趟拆分出来的"。
所以 Iris 用一个全局原子序列号给每次拆分分配一段连续序列号。
9.8.2 首包携带"原始 CreationInfo"
引擎源码摘录(FPartialNetBlob::InternalSerialize,删减):
CPP
WritePackedUint32(Writer, SequenceNumber);
if (Writer->WriteBool(IsFirstPart()))
{
WritePackedUint16(Writer, PartCount - 1U);
SerializeCreationInfo(Context, OriginalCreationInfo);
}
WritePackedUint16(Writer, PayloadBitCount);
Writer->WriteBitStream(Payload.GetData(), 0U, PayloadBitCount);关键点🔑:
只有第一片携带:总片数 +
OriginalCreationInfo。其它片只带:序列号 + 自己的 payload。
9.8.3 Split 算法:先序列化到临时缓冲,再切块
引擎源码摘录(FPartialNetBlob::SplitNetBlob,删减):
CPP
// We have no idea what the internals of the FNetBlob look like. We must serialize it to a temporary buffer.
TArray<uint32> Payload;
// Trial and error: grow buffer until serialization fits.// ...
Blob->Serialize(SubContext);
// ...
Writer.CommitWrites();
CurrentPayloadBitCount = Writer.GetPosBits();
// Reserve sequence numbers for all parts.
uint32 SequenceNumber = PartialNetBlobGlobalSequenceNumber.fetch_add(PartialBlobCount, std::memory_order_relaxed);
// Copy relevant data into each partial blob.
FPlatformMemory::Memcpy(PartialBlob->Payload.GetData(), SplitParams.Payload + PayloadWordOffset, PartialBlobWordCount*4U);9.8.3.1 📏 扩容重试策略详解
由于 Blob 的 Serialize 是黑盒(系统不知道它会写多少数据),Split 采用试探性扩容:
CPP
// PartialNetBlob.cpp 中的扩容逻辑(简化)
uint32 PayloadWordCount = InitialPayloadWordCount; // 初始大小for (;;)
{
Payload.SetNumUninitialized(PayloadWordCount);
Writer.InitBytes(Payload.GetData(), PayloadWordCount * 4U);
Blob->Serialize(SubContext);
Writer.CommitWrites();
if (!Writer.IsOverflown())
{
break; // 成功,缓冲区够大
}
// 溢出:扩容重试
PayloadWordCount *= 2; // 翻倍策略
if (PayloadWordCount > MaxPayloadWordCount)
{
// 超过最大限制,报错
Context.SetError(GNetError_BitStreamOverflow);
return false;
}
}关键参数:
初始大小:通常是
BitCountSplitThreshold / 32(即阈值对应的字数)扩容策略:翻倍(
PayloadWordCount *= 2)最大限制:由
USequentialPartialNetBlobHandlerConfig::MaxPartPayloadByteCount控制,默认约 64KB
把它画成流程图就更好懂了:
PLAINTEXT
原始 Blob
|
| Serialize → 临时 Payload(可能需要扩容重试)
v
Payload BitCount 已知
|
| 计算 PartCount,申请一段连续 SequenceNumber
v
循环切块:
- Part0:写 OriginalCreationInfo + PartCount
- PartN:只写 Sequence + Payload9.8.4 FNetBlobAssembler:把碎片拼回"原始 Blob"🧩
上面我们讲的是"怎么拆"。但真正能把功能跑通的关键是"怎么拼"。Iris 的答案是 FNetBlobAssembler:
它不关心你原始 Blob 的具体类型(因为碎片里首包携带了
OriginalCreationInfo)。它要求:碎片必须按顺序来,否则直接判定序列损坏(尤其是可靠序列)。
9.8.4.1 🔬 AddPartialNetBlob:顺序校验 + 缓冲拼接
引擎源码摘录(NetBlobAssembler.cpp,删减):
CPP
const uint32 SequenceNumber = PartialNetBlob->GetSequenceNumber();
const bool bIsFirstPart = PartialNetBlob->IsFirstPart();
const bool bIsReliable = PartialNetBlob->IsReliable();
if (SequenceNumber != NextSequenceSumber || bIsReliable != bIsProcessingReliable)
{
if (bIsProcessingReliable)
{
bIsBrokenSequence = true;
Context.SetError(NetError_PartialNetBlobSequenceError);
return;
}
if (!bIsFirstPart && SequenceNumber != NextSequenceSumber)
{
bIsBrokenSequence = true;
if (bIsReliable)
{
Context.SetError(NetError_PartialNetBlobSequenceError);
}
return;
}
}
if (bIsFirstPart)
{
NetBlobCreationInfo = PartialNetBlob->GetOriginalCreationInfo();
// 预分配足够大的 Payload 缓冲
Payload.SetNumUninitialized((MaxByteCountPerPart*PartCount + 3U)/4U);
BitWriter.InitBytes(Payload.GetData(), Payload.Num()*4U);
}
BitWriter.WriteBitStream(PartialNetBlob->GetPayload(), 0, PayloadBitCount);这段代码非常适合用"拼乐高"来讲:
每片都有"编号"(
SequenceNumber),你必须按编号拼。可靠拼装一旦发现中间断了:直接报错(因为可靠语义要求最终必须完整)。
9.8.4.2 🔬 Assemble:Create 原始 Blob → 用拼好的 bitstream 做一次真正的 Deserialize
引擎源码摘录(NetBlobAssembler.cpp,删减):
CPP
const TRefCountPtr<FNetBlob>& NetBlob = BlobHandler->CreateNetBlob(NetBlobCreationInfo);
// RawDataNetBlob 快路径:直接把 Payload Move 进去if (EnumHasAnyFlags(NetBlob->GetCreationInfo().Flags, ENetBlobFlags::RawDataNetBlob))
{
FRawDataNetBlob* RawDataNetBlob = static_cast<FRawDataNetBlob*>(NetBlob.GetReference());
const uint32 PayloadBitCount = BitWriter.GetPosBits();
BitWriter = FNetBitStreamWriter();
RawDataNetBlob->SetRawData(MoveTemp(Payload), PayloadBitCount);
}
else
{
FNetBitStreamReader BitReader;
BitReader.InitBits(Payload.GetData(), BitWriter.GetPosBits());
FNetSerializationContext ReadContext = Context.MakeSubContext(&BitReader);
ReadContext.SetTraceCollector(nullptr);
NetBlob->Deserialize(ReadContext);
// Bitstream mismatch?
if (BitWriter.GetPosBits() != BitReader.GetPosBits())
{
Context.SetError(GNetError_BitStreamError);
return nullptr;
}
}这段代码能讲出两个"非常工程"的点:
接收端并不是把
FPartialNetBlob交给业务;它会先"拼回原始 Blob",然后再像正常 Blob 一样走Deserialize。FRawDataNetBlob有快路径:不需要再走一遍读 bitstream 的反序列化逻辑,直接把 raw bits 搬进对象(省 CPU)。
9.9 📮 可靠与有序:FReliableNetBlobQueue 的滑动窗口
如果 FPartialNetBlob 解决的是"包裹太大",那 FReliableNetBlobQueue 解决的是"包裹必须送达且按顺序"。📮
9.9.1 关键数据结构:窗口大小 1024 + 复制记录(最多 4 段不连续序列)
引擎源码摘录(ReliableNetBlobQueue.h):
CPP
static constexpr uint32 MaxUnackedBlobCount = 1024U;
// A single ReplicationRecord supports up to four disjoint sequences...struct FReplicationRecord
{
FSequence Sequences[4];
};
inline bool FReliableNetBlobQueue::IsSendWindowFull() const{
return (LastSeq - FirstSeq) >= MaxUnackedBlobCount;
}生活类比:
这就是"挂号信窗口":一次最多挂 1024 封待确认的信。
FReplicationRecord支持"最多 4 段不连续"——很好理解:你可能在重传丢失片段的同时,还想塞一点新数据进去。
9.9.2 发送侧:SerializeInternal 会尽量写满包、支持不连续序列、并在 overflow 时回滚
引擎源码摘录(ReliableNetBlobQueue.cpp,删减):
CPP
for (uint32 Seq = FirstSeq, EndSeq = LastSeq; Seq < EndSeq; ++Seq)
{
const uint32 Index = Seq & IndexMask; // 环形缓冲区索引
// IsIndexSent:检查这个序列号是否已经在当前包里写过了
// 用于避免重复写入(比如重传场景下,同一个包可能包含多段序列)
if (IsIndexSent(Index)) { continue; }
FNetBitStreamRollbackScope RollbackScope(*Writer);
FNetExportRollbackScope ExportRollbackScope(Context);
// 不连续序列编码:如果当前 Seq 不是上一个写入的 Seq + 1,需要显式写入 Index
// PrevWrittenSeq:上一次成功写入的序列号,用于判断是否连续
if (Writer->WriteBool(Seq != PrevWrittenSeq + 1U))
{
Writer->WriteBits(Index, IndexBitCount); // 写入实际的环形索引
}
const TRefCountPtr<FNetBlob>& Attachment = Entries[Index].Attachment;
const bool bHasData = Attachment.GetRefCount() > 0; // Unreliable 可能已被释放
if (Writer->WriteBool(bHasData))
{
Attachment->SerializeCreationInfo(Context, Attachment->GetCreationInfo());
Attachment->Serialize(Context);
}
// HasMore 标志:告诉接收端是否还有更多数据
// 这里先写 false,如果后续还有数据会被覆盖
Writer->WriteBool(false); // HasMore
if (Writer->IsOverflown())
{
ExportRollbackScope.Rollback(); // 回滚导出break; // RollbackScope 析构时会自动回滚 bitstream
}
PrevWrittenSeq = Seq;
MarkIndexSent(Index);
}关键概念解释:
IsIndexSent(Index):位图标记,表示这个环形索引在当前包里是否已经写过。避免重传时重复写入同一数据。PrevWrittenSeq:上一次成功写入的序列号。用于不连续序列编码优化:如果序列号是连续的(Seq == PrevWrittenSeq + 1),只需要 1 bit 表示"连续";否则需要额外写入完整的 Index。HasMore标志:接收端用它来判断"这个包里是否还有更多 Blob"。发送端在循环中先写false,如果后续还有数据,会在下一次迭代开始时被新的WriteBool覆盖。回滚作用域(RollbackScope):写到一半包满了就撤回,避免半包脏数据。
Unreliable blob 可能被释放(
GetRefCount()检查):不可靠数据只发一次,丢了就算了。
9.9.3 接收侧:先校验序列号,再 Create,再 Deserialize
(这段我们在 9.10 会把"交给 Handler"串起来讲。)
9.10 📥 接收侧主流程:CreateNetBlob→Deserialize→OnNetBlobReceived
接收侧最容易让新手迷糊的点是:
谁来 Create?谁来 Deserialize?谁来最终处理?
答案是:
Create:
INetBlobReceiver::CreateNetBlob(通常是FNetBlobHandlerManager)Deserialize:Blob 自己(
Deserialize/DeserializeWithObject)最终处理:
FNetBlobHandlerManager::OnNetBlobReceived→ 具体UNetBlobHandler::OnNetBlobReceived
9.10.1 可靠队列里实际怎么做 Create/Deserialize
引擎源码摘录(ReliableNetBlobQueue.cpp,删减):
CPP
FNetBlobCreationInfo CreationInfo;
FNetBlob::DeserializeCreationInfo(Context, CreationInfo);
Blob = BlobReceiver->CreateNetBlob(CreationInfo);
if (!Blob.IsValid())
{
Context.SetError(GNetError_UnsupportedNetBlob);
return DeserializedCount;
}
Blob->Deserialize(Context);一句话总结🧾:
接收端先读
CreationInfo,靠Type找到对应 Handler,创建正确的 Blob 类型。然后 Blob 才能按自己的规则把 payload 解析出来。
9.10.2 两层队列的关系:FReliableNetBlobQueue vs FNetObjectAttachmentReceiveQueue
在深入分片处理之前,先理清这两个队列的关系:
PLAINTEXT
网络包到达
|
v
FReliableNetBlobQueue::Deserialize*
|
| 底层可靠队列:负责可靠/有序语义、序列号校验、重传检测
| 输出:反序列化后的 FNetBlob(可能是 FPartialNetBlob)
v
FNetObjectAttachmentReceiveQueue
|
| 上层接收队列:负责分片识别、组装、最终分发
| - 如果是 FPartialNetBlob → 交给 FNetBlobAssembler 组装
| - 如果是普通 Blob → 直接入队等待处理
v
FNetBlobHandlerManager::OnNetBlobReceived为什么要分两层?
FReliableNetBlobQueue:专注于"可靠有序传输",不关心 Blob 的具体类型。它只保证:按序列号顺序、可靠地把 Blob 反序列化出来。FNetObjectAttachmentReceiveQueue:专注于"附件的业务处理",包括分片组装、对象关联、最终分发。它知道"分片是一种特殊的 Blob",需要特殊处理。
9.10.3 接收端如何吃下"分片":延迟处理队列
如果你只看 FReliableNetBlobQueue,会以为"它 Deserialize 出来的就是最终 Blob"。但一旦启用了分片,接收端实际会多一层:
先把
FPartialNetBlob收齐用
FNetBlobAssembler拼回原始 Blob再把原始 Blob 放进待处理队列
这条链路在 AttachmentReplication.cpp 里写得非常清楚。
9.10.3.1 🔍 如何识别"这是一个 PartialNetBlob"?
引擎源码摘录(AttachmentReplication.cpp,删减):
CPP
PartialNetBlobType = (InitParams.PartialNetObjectAttachmentHandler ? InitParams.PartialNetObjectAttachmentHandler->GetNetBlobType() : InvalidNetBlobType);
bool FNetObjectAttachmentReceiveQueue::IsPartialNetBlob(const TRefCountPtr<FNetBlob>& Blob) const{
return Blob.IsValid() && Blob->GetCreationInfo().Type == PartialNetBlobType;
}翻译成人话🗣️:
UPartialNetObjectAttachmentHandler自己也有一个NetBlobType。所有的
FPartialNetBlob都会用这个 Type 发送(也就是说:包裹外箱上写的是"分片件")。
9.10.3.2 🕒 延迟队列:收到分片就喂给 Assembler,凑齐就组装
引擎源码摘录(AttachmentReplication.cpp,删减):
CPP
if (bIsPartialNetBlob)
{
TUniquePtr<FNetBlobAssembler>& NetBlobAssembler = bIsBlobReliable ? ReliableNetBlobAssembler : UnreliableNetBlobAssembler;
if (!NetBlobAssembler.IsValid())
{
FNetBlobAssemblerInitParams InitParams;
InitParams.PartialNetBlobHandlerConfig = PartialNetObjectAttachmentHandler ? PartialNetObjectAttachmentHandler->GetConfig() : nullptr;
NetBlobAssembler = MakeUnique<FNetBlobAssembler>();
NetBlobAssembler->Init(InitParams);
}
NetBlobAssembler->AddPartialNetBlob(Context, RefHandle, reinterpret_cast<const TRefCountPtr<FPartialNetBlob>&>(NetBlob));
if (NetBlobAssembler->IsReadyToAssemble() || NetBlobAssembler->IsSequenceBroken())
{
if (NetBlobAssembler->IsReadyToAssemble())
{
const TRefCountPtr<FNetBlob>& AssembledBlob = NetBlobAssembler->Assemble(Context);
if (AssembledBlob.IsValid())
{
Queue.Enqueue(AssembledBlob);
}
}
NetBlobAssembler.Reset();
}
}
else
{
Queue.Enqueue(NetBlob);
}把它画成"接收侧流水线"会更直观:
PLAINTEXT
反序列化得到 NetBlob
|
v
IsPartialNetBlob ?
|是 |否
v v
Assembler.AddPart 直接入队
|
+--> ReadyToAssemble ? --是--> Assemble() 得到原始 Blob --> 入队
|
+--> SequenceBroken ? --是--> 报错/丢弃并 Reset9.10.3.3 🧷 一个细节:可靠/不可靠的分片组装是两套 Assembler
你会注意到代码里用了两个成员:ReliableNetBlobAssembler 和 UnreliableNetBlobAssembler。
可靠分片:要求严格按序,断了就错。
不可靠分片:更像"尽力而为",并且在某些只处理不可靠的场景,会为了尽快释放队列而主动
Reset()。
9.10.4 FNetBlobHandlerManager::OnNetBlobReceived:最后一步"分发到柜台"
引擎源码摘录(NetBlobHandlerManager.cpp,删减):
CPP
const FNetBlobCreationInfo& CreationInfo = Blob->GetCreationInfo();
UNetBlobHandler* Handler = Handlers[CreationInfo.Type].Get();
return Handler->OnNetBlobReceived(Context, Blob);到这里你就能把"端到端链路"闭环了:
Type 决定柜台
柜台决定 Create + 处理逻辑
9.10.5 发送/接收队列全景图(含"窗口满/丢不可靠/重传")🗺️
上面 9.6~9.10 你已经理解了"分片/可靠/Handler 分发"。但在真实项目里,问题通常出在队列层级:你以为"发了",其实卡在某个队列里;你以为"可靠=必达",其实卡在可靠窗口,导致后续一直 stalled。
下面这张图把 NetBlob 从"入队"到"被业务处理"的关键队列层级串起来(你排查问题可以按图从上往下走):
PLAINTEXT
发送侧(Gameplay → Packet)
Gameplay / RPC / Attachment
|
v
FNetBlobManager::QueueNetObjectAttachment / SendRPC
|
| ① 进入调度队列(普通 AttachmentQueue 或 OOB ScheduleAsOOBAttachmentQueue)
v
FNetBlobManager::FNetObjectAttachmentSendQueue::ProcessQueue
|
| ② 预序列化 & 决策:ShrinkWrap 或 Split(FPartialNetBlob...)
v
FReplicationWriter::QueueNetObjectAttachments
|
| ③ 进入每连接/每对象的发送队列(AttachmentReplication.cpp)
v
FNetObjectAttachmentSendQueue
| |
| Reliable / Ordered | Unreliable
| |
v v
FReliableSendQueue RingQueue(容量受限,满了丢旧的)
- PreQueue(可很大) - 只发一次,丢了就算了
- FReliableNetBlobQueue(窗口=1024)
|
| ④ 写入包(Serialize*)并生成 ReplicationRecord
v
Packet
|
| ⑤ 通过 PacketNotification 回调交付状态
v
ProcessPacketDeliveryStatus(Delivered/Lost)
- Delivered: 释放已确认/推进窗口
- Lost: 标记未发送 → 触发重传接收侧(Packet → Gameplay)
Packet
|
v
FReliableNetBlobQueue::Deserialize*
|
| ⑥ 先校验序列号,再 Create,再 Deserialize
v
得到 NetBlob(可能是 FPartialNetBlob)
|
v
FNetObjectAttachmentReceiveQueue
| 是 Partial? | 否
v vFNetBlobAssembler::AddPartialNetBlob 直接入队
|
+--> ReadyToAssemble → Assemble() 得到"原始 Blob"
|
v
FNetBlobHandlerManager::OnNetBlobReceived
|
v
UNetBlobHandler::OnNetBlobReceived(业务)9.10.5.1 🧭 排查顺序(你写博客时可以直接给读者一套"定位路径")
发不出去:先看是不是卡在
FNetBlobManager的两条队列(普通 / OOB)。发了但不动:看
FNetObjectAttachmentSendQueue的返回状态是不是ReliableWindowFull(窗口满会导致可靠附件持续排队)。收了但业务没触发:看是不是分片没拼齐(
FNetBlobAssembler没有ReadyToAssemble),或Type对不上导致CreateNetBlob失败。偶发"错包/乱序":优先怀疑
UNetBlobHandlerDefinitions的顺序不一致(Type分配变化)。
9.10.5.2 🗺️ 读源码路线图(按"你想回答的问题"索引)
我在 Gameplay 调用后,Blob 到底进了哪个队列?
NetBlobManager.cpp:QueueNetObjectAttachment/SendRPC→FNetObjectAttachmentSendQueue::Enqueue。
什么时候 ShrinkWrap,什么时候 Split?
PartialNetObjectAttachmentHandler.cpp:PreSerializeAndSplitNetBlob(阈值写爆才 Split)。
Split 的序列号、首包携带什么?
PartialNetBlob.cpp:InternalSerialize/SplitNetBlob。
可靠窗口/重传/交付状态怎么推进?
ReliableNetBlobQueue.h/.cpp:SerializeInternal/ProcessPacketDeliveryStatus/OnPacketDropped/OnPacketDelivered。
为什么我看到 ReliableWindowFull?
AttachmentReplication.cpp:FNetObjectAttachmentSendQueue::Serialize(窗口满直接返回ReliableWindowFull)。
接收侧分片在哪拼?拼完去哪?
AttachmentReplication.cpp:FNetObjectAttachmentReceiveQueue(识别PartialNetBlobType,Assembler 拼完才入队)。
最后怎么分发到我的 Handler?
NetBlobHandlerManager.cpp:OnNetBlobReceived/RegisterHandler。
9.11 🎮 游戏场景:敌人 AI 决策、区域初始化、回放/排行榜
9.11.1 先做选型:属性复制 vs RPC vs NetBlob(从约束出发)
你可以把常见需求按三个维度切分:
频率:高频(每帧/每几帧) vs 低频(偶发/每秒级)
体积:小(几十字节) vs 大(上 KB~MB)
一致性要求:必须一致(状态机/结算) vs 允许丢(纯表现)
快速决策表(博客可直接用):
需求 | 更合适的方式 | 原因 |
|---|---|---|
高频、可增量(位置/速度/Anim 状态) | 属性复制 | 系统天然做增量/压缩/频控 |
低频、小事件(播放音效/表情) | RPC / Unreliable Blob | 事件语义清晰,丢了影响小 |
低频、必须一致(状态机跳转/关键技能) | Reliable RPC / Reliable Blob | 必达且按序,避免"先爆炸后抬手" |
低频、体积大(区域初始化/快照/回放块) | NetBlob(可 Split) | 一次性大件,系统提供分片与组装 |
9.11.2 敌人 AI 决策:从"需求"到"Flags/SendPolicy"的完整落地
假设你要同步一个敌人 AI 的"决策结果",并且希望:
客户端表现尽量一致(关键状态不能错)
非关键表现尽量省带宽(丢了也不心疼)
把数据拆成三层,会非常好讲:
持续状态(State):例如
MoveMode/TargetId/bIsEnraged→ 属性复制。关键事件(Event, must happen):例如"进入狂暴""技能释放(参数)" → Reliable + Ordered。
纯表现事件(Cosmetic, best effort):例如"转头""嘲讽表情""脚步粒子" → Unreliable(可选 Ordered)。
对比表(升级版:把 SendPolicy 也写出来):
事件/数据 | 推荐方式 |
|
| 解释 |
|---|---|---|---|---|
敌人位置/速度 | 属性复制 | - | - | 高频小数据,增量最划算 |
转头/表情 | Unreliable RPC / Blob |
| 一般默认 | 丢了最多少一次表现 |
进入关键状态(狂暴/无敌) | Reliable RPC / Blob |
| 必要时 | 必须一致,且最好尽快到 |
技能释放(多段连招) | Reliable + Ordered |
| 一般默认 | 防止乱序导致表现穿帮 |
9.11.2.1 📝 什么时候该用 ScheduleAsOOB?
用在"晚一点就失去意义"的数据(例如"立刻打断/立刻进入无敌帧"的指令)。
但不要滥用:OOB 会改变调度优先级,也会影响"附件随对象一起发"的策略(见 9.6)。
9.11.2.2 🎯 端到端示例:敌人进入狂暴状态
下面是一个完整的"敌人进入狂暴"NetBlob 实现,从 Blob 定义到发送到接收处理:
CPP
// ========== 1. 定义 Blob ==========// EnragedStateBlob.hclass FEnragedStateBlob : public UE::Net::FNetObjectAttachment
{
public:
// 狂暴状态数据
float DamageMultiplier = 1.0f;
float SpeedMultiplier = 1.0f;
float Duration = 0.0f;
uint8 EnrageLevel = 0;
FEnragedStateBlob(const UE::Net::FNetBlobCreationInfo& InCreationInfo)
: FNetObjectAttachment(InCreationInfo)
{
}
virtual void Serialize(UE::Net::FNetSerializationContext& Context) const override
{
auto* Writer = Context.GetBitStreamWriter();
Writer->WriteBits(EnrageLevel, 4); // 0-15 级狂暴// 使用量化减少带宽
UE::Net::WriteQuantizedFloat(Writer, DamageMultiplier, 1.0f, 5.0f, 8); // 1x-5x, 8bit
UE::Net::WriteQuantizedFloat(Writer, SpeedMultiplier, 1.0f, 3.0f, 8); // 1x-3x, 8bit
UE::Net::WriteQuantizedFloat(Writer, Duration, 0.0f, 60.0f, 10); // 0-60s, 10bit
}
virtual void Deserialize(UE::Net::FNetSerializationContext& Context) override
{
auto* Reader = Context.GetBitStreamReader();
EnrageLevel = Reader->ReadBits(4);
UE::Net::ReadQuantizedFloat(Reader, DamageMultiplier, 1.0f, 5.0f, 8);
UE::Net::ReadQuantizedFloat(Reader, SpeedMultiplier, 1.0f, 3.0f, 8);
UE::Net::ReadQuantizedFloat(Reader, Duration, 0.0f, 60.0f, 10);
}
};
// ========== 2. 定义 Handler ==========// EnragedStateBlobHandler.hUCLASS()
class UEnragedStateBlobHandler : public UNetBlobHandler
{
GENERATED_BODY()
public:
// 用于外部获取 Handler 实例
static UEnragedStateBlobHandler* Get(UReplicationSystem* RepSystem);
protected:
virtual TRefCountPtr<UE::Net::FNetBlob> CreateNetBlob(
const UE::Net::FNetBlobCreationInfo& Info) const override
{
return new FEnragedStateBlob(Info);
}
virtual void OnNetBlobReceived(
UE::Net::FNetSerializationContext& Context,
const TRefCountPtr<UE::Net::FNetBlob>& Blob) override
{
const FEnragedStateBlob* EnragedBlob =
static_cast<const FEnragedStateBlob*>(Blob.GetReference());
// 获取目标对象const FNetObjectAttachment* Attachment =
static_cast<const FNetObjectAttachment*>(Blob.GetReference());
AActor* TargetActor = ResolveNetObjectReference(Attachment->GetTargetObjectReference());
if (AEnemyCharacter* Enemy = Cast<AEnemyCharacter>(TargetActor))
{
// 应用狂暴状态
Enemy->ApplyEnragedState(
EnragedBlob->EnrageLevel,
EnragedBlob->DamageMultiplier,
EnragedBlob->SpeedMultiplier,
EnragedBlob->Duration
);
// 播放客户端表现
Enemy->PlayEnrageVFX();
}
}
};
// ========== 3. 服务端发送 ==========// EnemyCharacter.cpp (Server)void AEnemyCharacter::Server_EnterEnragedState(uint8 Level, float Duration){
// 计算狂暴参数
float DamageMult = 1.0f + Level * 0.5f;
float SpeedMult = 1.0f + Level * 0.2f;
// 创建 Blob
UE::Net::FNetBlobCreationInfo CreationInfo;
CreationInfo.Flags = UE::Net::ENetBlobFlags::Reliable; // 关键状态,必须可靠
TRefCountPtr<FEnragedStateBlob> Blob = new FEnragedStateBlob(CreationInfo);
Blob->EnrageLevel = Level;
Blob->DamageMultiplier = DamageMult;
Blob->SpeedMultiplier = SpeedMult;
Blob->Duration = Duration;
// 获取 NetBlobManager 并发送
if (UReplicationSystem* RepSystem = GetReplicationSystem())
{
FNetBlobManager* BlobManager = RepSystem->GetNetBlobManager();
FNetObjectReference TargetRef = RepSystem->GetNetObjectReference(this);
// 多播给所有连接(ConnectionId = 0)// 使用 ScheduleAsOOB 让状态尽快送达
BlobManager->QueueNetObjectAttachment(
0, // 0 = 多播
TargetRef,
Blob,
ENetObjectAttachmentSendPolicyFlags::ScheduleAsOOB
);
}
}配置步骤(别忘了!):
INI
; DefaultEngine.ini[/Script/IrisCore.NetBlobHandlerDefinitions]
+NetBlobHandlerDefinitions=(ClassName="EnragedStateBlobHandler")9.11.3 区域初始化:大快照如何避免把可靠窗口打满(并让读者知道该怎么调)
区域初始化常见的工程约束是:
数据大(上 KB~MB)
必须一致(丢了会导致"这一片地形/掉落/怪物"完全不同步)
通常发生在"进区域/Join In Progress/切换关卡"这种关键时刻
推荐讲法:把区域初始化当成"一个可分片的 Reliable NetBlob"。
Flags:优先
Reliable。为什么不用堆 RPC:RPC 多条会带来更高的包头/调度开销,并且你要自己处理乱序/去重。
9.11.3.1 ⚠️ 关键提醒:可靠窗口(1024)不是无限的
区域快照很大时,Split 会产生很多 FPartialNetBlob。
如果短时间内你给同一连接塞了太多可靠附件,就可能出现
ReliableWindowFull。这时"看起来像卡死":因为可靠窗口不推进,你后续可靠数据也发不出去。
宁可分批发多个快照 chunk(每个都是一个 Blob),也不要一次性把一个连接的可靠窗口灌爆。
如果你必须一次性很大:至少让读者知道要看
ReliableWindowFull/以及相关队列大小 CVar(见 9.12.4)。
9.11.4 回放/排行榜:FRawDataNetBlob 适合"我已经有 bitstream 了"
引擎源码摘录(RawDataNetBlob.h):
CPP
// Helper class for stateless data...// Things like splitting and assembling have optimized code paths for this type of blob.class FRawDataNetBlob : public FNetBlob
{
IRISCORE_API void SetRawData(TArray<uint32>&& RawData, uint32 RawDataBitCount);
TArrayView<const uint32> GetRawData() const;
uint32 GetRawDataBitCount() const;
};一句话:
如果你已经把回放/排行榜压成一段 bitstream,
FRawDataNetBlob能让你少走很多"二次序列化"的弯路(Split/Assemble 还有快路径)。
9.11.4.1 ⚠️ 这类数据最容易踩的坑:Exports
回放/排行榜经常包含对象引用、字符串 token、NetToken 等外部依赖。
只要涉及引用,一定要关注
HasExports(见 9.7.3)。否则会出现"数据到了,但引用没到"的半残状态:读者会以为自己 Serialize/Deserialize 写错,其实是导出没带全。
9.12 ⚠️ 实用技巧与踩坑清单
9.12.1 最常见的 6 个坑(写博客很适合做成 Checklist)✅
坑 1:自定义 Handler 注册失败
症状:
RegisterHandler找不到 class name,日志 warning原因:不在
UNetBlobHandlerDefinitions的定义列表里建议:写博客时强调"Definitions 是白名单"
坑 2:序列化/反序列化不对称
症状:接收端读错位、Context 报错、后续包都被污染
建议:对每个字段都成对写,必要时写版本号
坑 3:把大数据做成不可靠
症状:分片丢一片就永远组不回来
建议:大块初始化/快照通常用
Reliable
坑 4:过度分片
症状:吞吐下降、延迟上升、包头开销爆炸
建议:别盲目调大
MaxPartCount,先理解阈值(BitCountSplitThreshold)
坑 5:多播场景忽略复用序列化
症状:CPU 上升,反复 Serialize 同一份数据
建议:理解
ShrinkWrap的价值:一次序列化,多人发送
坑 6:忽视
HasExports症状:对象引用/Token 导出不完整,接收端解析失败或引用为空
建议:关注
HasExports的语义,以及拆分首包携带 exports 的策略
9.12.2 "调参直觉"📌
分片不是越早越好:先 ShrinkWrap,只有写爆了才 Split。
可靠窗口不是无限大:
MaxUnackedBlobCount = 1024,窗口满了就入队失败。不可靠数据的哲学:它就是"发一次,丢了算了",别指望它像可靠那样重传。
9.12.3 自定义 Handler 落地步骤:让它进入 Definitions 白名单(并保持双方一致)🧾
专门解决 坑 1:注册失败。
第 1 步:实现
UNetBlobHandler子类你需要实现
CreateNetBlob(...)和OnNetBlobReceived(...)。注意:
Handler->GetClass()->GetName()用的是 UClass 名字(通常不带U前缀)。例如 C++ 类UChatMessageBlobHandler的GetName()常见是ChatMessageBlobHandler。
第 2 步:确保两端都
RegisterNetBlobHandler自定义类型要能收,就要"客户端/服务端都注册"(否则会出现接收端
CreateNetBlob返回空,报GNetError_UnsupportedNetBlob)。
第 3 步:把 Handler 加入
UNetBlobHandlerDefinitions(白名单)UNetBlobHandlerDefinitions的NetBlobHandlerDefinitions数组是UPROPERTY(Config),因此你可以在 INI 里配置。
⚠️ 重要说明:模块名取决于你的引擎版本和构建配置。在 UE 5.5 中,Iris 核心模块名为
IrisCore。如果配置不生效,请检查:
确认模块名是否正确(可以在
UNetBlobHandlerDefinitions的UCLASS()宏里查看config=参数)确认 INI 文件是否被正确加载(
DefaultEngine.inivs 项目特定配置)某些版本可能需要在 C++ 中手动添加到数组
INI
; DefaultEngine.ini; 让 NetBlobHandlerManager 能找到你的 Handler(注意:顺序会影响 Type 分配)[/Script/IrisCore.NetBlobHandlerDefinitions]
+NetBlobHandlerDefinitions=(ClassName="ChatMessageBlobHandler")
+NetBlobHandlerDefinitions=(ClassName="EnragedStateBlobHandler")如果 INI 配置不生效,可以在 C++ 中手动注册:
CPP
// 在游戏模块初始化时void FMyGameModule::StartupModule(){
// 获取 Definitions 并添加
UNetBlobHandlerDefinitions* Definitions = GetMutableDefault<UNetBlobHandlerDefinitions>();
FNetBlobHandlerDefinition ChatDef;
ChatDef.ClassName = FName(TEXT("ChatMessageBlobHandler"));
Definitions->NetBlobHandlerDefinitions.AddUnique(ChatDef);
}第 4 步:保证 Definitions 的"内容与顺序"在所有端一致
因为
Type就是数组下标,而且默认只有 7 bit(最多 128 个)。写博客时建议加一句醒目的提醒:
不要在已上线版本中随意插入/调整顺序(这会改变
Type,导致跨版本/跨端不兼容)。
9.12.4 队列/窗口/调试速查:一眼看懂"卡哪了"🧰
这一小节的目标是:遇到问题时,不用翻半天源码,先用"直觉"定位。
9.12.4.1 🚧 可靠窗口满(ReliableWindowFull)意味着什么?
本质:
FReliableNetBlobQueue的发送窗口上限是MaxUnackedBlobCount = 1024。现象:可靠附件(含 Split 产生的
FPartialNetBlob)无法继续写入包,表现为"可靠数据不再前进"。处理思路:减少短时间内的可靠附件数量(分批/节流),或者让先前的可靠包更快被 ACK(不要把链路堵死)。
9.12.4.2 🗑️ 不可靠队列丢弃:为什么你"发了"但看不到?
不可靠附件(Unreliable)如果积压,会触发"丢旧的"策略:为了避免队列无限膨胀。
写博客时建议强调:Unreliable 的哲学是 best-effort,丢了是设计预期。
9.12.4.3 🧰 常用 CVar 速查(与本文各段落对应)
这些名字都出自引擎源码(主要在 NetBlobManager.cpp / AttachmentReplication.cpp):
RPC/附件开关与行为
net.Iris.EnableRPCsnet.Iris.RPC.AllowOnDormantObjectsnet.Iris.RPC.AutoNetFlushOnDormantObjectsnet.Iris.ThrottleRPCWarnings
发送队列容量(影响"丢弃/积压/窗口前的缓冲")
net.UnreliableRPCQueueSize:每对象不可靠队列最大条数(超出会丢旧的)net.ReliableRPCQueueSize:每对象可靠"预队列"容量(用于支持很大的可靠 RPC/附件)net.ClientToServerUnreliableRPCQueueSize:客户端→服务端的不可靠队列大小net.MaxSimultaneousObjectsWithRPCs:同时允许多少对象"有待发送 RPC/附件"
Split 阈值(影响"何时拆/拆多少")
UPartialNetObjectAttachmentHandlerConfig(见 9.7.1):BitCountSplitThreshold/ClientUnreliableBitCountSplitThreshold/ServerUnreliableBitCountSplitThreshold
9.12.4.4 🧾 你可以教读者看的 3 条日志/错误(最值回票价)
GNetError_UnsupportedNetBlob:通常是Type对不上(Definitions 顺序/缺注册)或接收端没注册 Handler。NetError_PartialNetBlobSequenceError:分片序列断了(可靠语义下通常是硬错误)。GNetError_BitStreamError:高概率是 Serialize/Deserialize 不对称,或者读写位置不一致。
9.13 🧾 本章小结 + 关键源文件索引
9.13.1 本章一句话总结
NetBlob 是 Iris 的"可控大包裹"📦:FNetBlobManager 负责排队与调度,UPartialNetObjectAttachmentHandler 负责"先试装再决定拆不拆",FPartialNetBlob 解决"太大要拆",FReliableNetBlobQueue 解决"必须送达且按序",最终由 FNetBlobHandlerManager 按 Type 把包裹交给正确的 UNetBlobHandler 处理。
9.13.2 关键源文件(写博客时建议放到文末)
文件 | 作用 |
|---|---|
|
|
|
|
| 类型分发与注册( |
| 调度中心:入队、处理队列、默认 Handler 注册、OOB 队列 |
| 预序列化阈值、ShrinkWrap vs Split 的关键分岔 |
| 分片结构、序列号、首包携带原始 CreationInfo |
| 可靠有序队列:滑动窗口、重传、记录 |
| ShrinkWrap 思想与两个包装类型 |
| 原始 bitstream Blob(优化分片/组装路径) |
本文档基于 Unreal Engine 5.5.0 Iris 源代码分析(源码目录:Engine/Source/Runtime/Experimental/Iris/)