🏗️ Iris 网络复制系统技术分析 - 第三部分:核心数据结构

📦 前言:这些数据结构是干什么的?
在深入源码之前,先用一个生活中的比喻来理解这四个核心数据结构:
PLAINTEXT
想象你是一个快递公司的调度员,需要管理全国的包裹配送:
┌─────────────────────────────────────────────────────────────────────────┐
│ 快递公司 vs Iris 网络复制 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 快递单号 (如: SF1234567890) │
│ ─────────────────────────── │
│ 作用:唯一标识一个包裹,发货方和收货方都用这个号码追踪同一个包裹 │
│ │
│ 对应 Iris:FNetRefHandle │
│ 作用:唯一标识一个网络对象,服务器和客户端都用这个句柄指代同一个对象 │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ 包裹清单 (物品名称、数量、重量、尺寸...) │
│ ─────────────────────────────────────── │
│ 作用:描述包裹里有什么东西,每样东西放在哪个位置 │
│ │
│ 对应 Iris:FReplicationStateDescriptor │
│ 作用:描述一个对象有哪些属性需要同步,每个属性在内存中的位置 │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ 快递员 (负责取件、打包、派送) │
│ ───────────────────────────── │
│ 作用:根据清单,从发货方取出物品打包;送到后,按清单把物品交给收货方 │
│ │
│ 对应 Iris:FReplicationFragment │
│ 作用:从游戏对象读取属性值(取件);把收到的值写入游戏对象(派送) │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ 配送协议 (标准件/冷链/易碎品...) │
│ ─────────────────────────────── │
│ 作用:规定这类包裹怎么处理,需要哪些特殊流程 │
│ │
│ 对应 Iris:FReplicationProtocol │
│ 作用:规定这类对象怎么复制,包含哪些状态描述符 │
│ │
└─────────────────────────────────────────────────────────────────────────┘📋 简单总结:
数据结构 | 一句话解释 | 生活比喻 |
|---|---|---|
🏷️ FNetRefHandle | 对象的"身份证号" | 快递单号 |
📝 FReplicationStateDescriptor | 属性的"说明书" | 包裹清单 |
🚚 FReplicationFragment | 数据的"搬运工" | 快递员 |
📖 FReplicationProtocol | 复制的"规则手册" | 配送协议 |
现在让我们深入了解每个数据结构的细节 👇
🏷️ 2.1 FNetRefHandle(网络对象句柄)
📌 概述
FNetRefHandle 是 Iris 系统中用于唯一标识网络复制对象的核心句柄类型。每个参与网络复制的对象都会被分配一个 FNetRefHandle,用于在整个复制系统中追踪和引用该对象。
💡 新手理解:你可以把
FNetRefHandle想象成游戏对象的"网络身份证"。就像每个人都有唯一的身份证号,每个需要网络同步的对象都有唯一的 Handle。
❓ 为什么需要 FNetRefHandle?
在网络游戏中,服务器和客户端需要对同一个游戏对象达成共识。例如:
PLAINTEXT
场景:服务器生成了一个敌人 Actor
服务器端:
- 内存地址:0x7FFF12340000 (AEnemy*)
- 需要告诉客户端"这个敌人的血量变了"
客户端端:
- 内存地址:0x7FFF56780000 (AEnemy*) ← 不同的地址!
- 需要知道"服务器说的是哪个敌人?"❌ 问题:内存地址在不同机器上是不同的,无法直接用指针通信。
✅ 解决方案:给每个网络对象分配一个全局唯一的 ID(句柄),服务器和客户端都用这个 ID 来指代同一个对象。
PLAINTEXT
服务器:AEnemy* → FNetRefHandle(Id=42) → 发送 "Handle 42 的血量=50"
↓
客户端:收到 "Handle 42 的血量=50" → FNetRefHandle(Id=42) → AEnemy*CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationSystem/NetRefHandle.h
namespace UE::Net
{
class FNetRefHandle
{
public:
inline static FNetRefHandle GetInvalid() { return FNetRefHandle(); }
private:
enum { InvalidValue = 0 };
enum { IdBits = 60 }; // ID 占用 60 位
enum { ReplicationSystemIdBits = 4 }; // ReplicationSystemId 占用 4 位
public:
FNetRefHandle() : Value(InvalidValue) {}
uint64 GetId() const { return Id; }
uint32 GetReplicationSystemId() const { check(ReplicationSystemId != 0); return (uint32)(ReplicationSystemId - 1); }
bool IsValid() const { return Value != InvalidValue; }
bool IsCompleteHandle() const { return Value != InvalidValue && ReplicationSystemId != 0U; }
bool IsStatic() const { return Id & StaticIdMask; }
bool IsDynamic() const { return IsValid() && !IsStatic(); }
bool operator==(const FNetRefHandle& Other) const { return Id == Other.Id; }
bool operator<(const FNetRefHandle& Other) const { return Id < Other.Id; }
bool operator!=(const FNetRefHandle& Other) const { return Id != Other.Id; }
private:
static constexpr uint64 StaticIdMask = 1;
static constexpr uint64 IdMask = (1ULL << IdBits) - 1;
static constexpr uint64 MaxReplicationSystemId = (1ULL << ReplicationSystemIdBits) - 1;
union
{
struct
{
uint64 Id : IdBits; // 60 位 ID,最低位表示静态/动态
uint64 ReplicationSystemId : ReplicationSystemIdBits; // 4 位 ReplicationSystemId
};
uint64 Value;
};
};
}🔢 位掩码常量详解
💡 新手提示:如果你对位运算不熟悉,可以先跳过这部分,不影响理解后续内容。这里主要是解释源码中的实现细节。
源码中定义了两个关键的位掩码常量,用于位操作和数值限制:
CPP
static constexpr uint64 IdMask = (1ULL << IdBits) - 1;
// 其中 IdBits = 60
static constexpr uint64 MaxReplicationSystemId = (1ULL << ReplicationSystemIdBits) - 1;
// 其中 ReplicationSystemIdBits = 4🤔 什么是位掩码?为什么需要它?
先用一个简单的比喻理解位掩码:
PLAINTEXT
想象你有一个 8 位数字密码:12345678
你想要:
- 只取前 4 位 → 1234- 只取后 4 位 → 5678
位掩码就是帮你"挡住"不需要的部分,只留下需要的部分。在计算机中,我们用二进制的 1 和 0 来做这件事:
PLAINTEXT
原始数据: 1010 1100 (十进制 172)位掩码: 0000 1111 (十进制 15,4 个 1)─────────────────────按位与(&): 0000 1100 (只保留了低 4 位)🧮 IdMask 的计算过程
PLAINTEXT
步骤1: 1ULL << 60
= 1 × 2^60
= 0x1000000000000000 (二进制:1 后面跟 60 个 0)
步骤2: (1ULL << 60) - 1
= 0x1000000000000000 - 1
= 0x0FFFFFFFFFFFFFFF (二进制:60 个 1)🤔 为什么 (1 << N) - 1 能得到 N 个 1?
PLAINTEXT
用小例子理解(假设 N = 4):
1 << 4 = 10000 (二进制,1 后面 4 个 0)
= 16 (十进制)
16 - 1 = 15
= 01111 (二进制,4 个 1)
这就像:
10000
- 1
───────
01111📊 IdMask 的作用:
✅ 提取 64 位值中的低 60 位(ID 部分)
✅ 限制 ID 值不超过 60 位
✅ 可表示约 ≈ 1.15 × 10^18(超过 115 亿亿)个不同对象
🧮 MaxReplicationSystemId 的计算过程
PLAINTEXT
步骤1: 1ULL << 4
= 1 × 2^4
= 16
= 0x10 (二进制:10000)
步骤2: (1ULL << 4) - 1
= 16 - 1
= 15
= 0xF (二进制:1111,即 4 个 1)📊 MaxReplicationSystemId 的作用:
✅ 定义复制系统 ID 的最大值为 15
✅ 限制复制系统 ID 在 0-15 范围内
✅ 支持最多 16 个并行的复制系统实例
💻 位操作应用示例
CPP
// 快速提取 ID(低 60 位)
uint64 id = handle.Value & IdMask;
// 快速提取系统 ID(高 4 位)
uint64 systemId = (handle.Value >> IdBits) & MaxReplicationSystemId;
// 快速比较(只比较 ID 部分,忽略系统 ID)bool sameId = (handle1.Value & IdMask) == (handle2.Value & IdMask);
// 验证系统 ID 有效性if (systemId <= MaxReplicationSystemId) {
// 有效的复制系统 ID
}🎯 为什么使用这种位布局设计?
优势 | 说明 |
|---|---|
💾 内存效率 | 64 位可表示 种组合 |
⚡ 位操作优化 | 掩码操作比除法/取模快数十倍 |
🔒 类型安全 | 通过位掩码确保数据不会溢出到其他字段 |
🚀 缓存友好 | 单个 64 位值,一次内存访问即可获取所有信息 |
🗂️ 句柄结构与位布局
💡 新手理解:下面这张图展示了 64 位句柄的内部结构。你可以把它想象成一个有两个格子的盒子:大格子放 ID(60 位),小格子放系统 ID(4 位)。
FNetRefHandle 使用 64 位整数存储,采用联合体(union)实现高效的位操作:
PLAINTEXT
┌─────────────────────────────────────────────────────────────────────┐
│ 64-bit FNetRefHandle │
├─────────────────────────────────────────────────────────────────────┤
│ 63 60 │ 59 0 │
│ ┌───────┐│┌──────────────────────────────────────────────────────┐│
│ │RepSysId││ Id (60 bits) ││
│ │(4 bits)││ ││
│ └───────┘│└──────────────────────────────────────────────────────┘│
│ │ ↑ │
│ │ Static Bit │
└─────────────────────────────────────────────────────────────────────┘
简化理解:
┌──────────────────────────────────────────────────────────────────────┐
│ 高 4 位 │ 低 60 位 │
│ ┌──────────────┐ │ ┌────────────────────────────────────────┐│
│ │ 系统 ID │ │ │ 对象 ID │静态标志位 ││
│ │ (0-15) │ │ │ (唯一编号) │(最低1位) ││
│ └──────────────┘ │ └────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────┘字段 | 位数 | 说明 |
|---|---|---|
Id | 60 位 | 对象的唯一标识符 |
ReplicationSystemId | 4 位 | 复制系统实例 ID(PIE 支持) |
Static Bit | 1 位(Id 最低位) | 标识静态/动态句柄 |
🔀 静态句柄 vs 动态句柄
💡 新手理解:
🏠 静态句柄:关卡设计师在编辑器里放置的物体(比如地图上的一棵树、一个门)
🎯 动态句柄:游戏运行时生成的物体(比如玩家发射的子弹、刷新的怪物)
Iris 区分两种类型的句柄:
类型 | 判断条件 | 典型用途 | 生活比喻 |
|---|---|---|---|
🏠 静态句柄 |
| 关卡中预放置的 Actor、静态资源引用 | 门牌号(固定不变) |
🎯 动态句柄 |
| 运行时生成的 Actor、动态创建的对象 | 临时工牌(每次不同) |
CPP
// 判断句柄类型bool IsStatic() const { return Id & StaticIdMask; } // StaticIdMask = 1bool IsDynamic() const { return IsValid() && !IsStatic(); }🔍 为什么要区分静态和动态?
PLAINTEXT
场景:一个多人游戏关卡
静态对象(关卡里预先放好的):
┌─────────────────────────────────────────────────────────────────────────┐
│ 服务器和所有客户端都加载同一个关卡文件 │
│ │
│ 服务器:关卡里有一扇门 → 静态 Handle = 3 │
│ 客户端A:加载同一关卡 → 同一扇门也是 Handle = 3 ✓ 自动匹配! │
│ 客户端B:加载同一关卡 → 同一扇门也是 Handle = 3 ✓ 自动匹配! │
│ │
│ 优势:不需要网络传输来建立对应关系,因为大家都从同一个关卡文件加载 │
└─────────────────────────────────────────────────────────────────────────┘
动态对象(游戏运行时生成的):
┌─────────────────────────────────────────────────────────────────────────┐
│ 服务器生成一个敌人 → 分配动态 Handle = 2 │
│ │
│ 服务器:告诉客户端"我生成了 Handle=2 的敌人" │
│ 客户端A:收到消息 → 本地创建敌人 → 记住"这个敌人是 Handle=2" │
│ 客户端B:收到消息 → 本地创建敌人 → 记住"这个敌人是 Handle=2" │
│ │
│ 注意:动态 Handle 由服务器分配,客户端必须使用服务器给的 Handle │
└─────────────────────────────────────────────────────────────────────────┘🏠 静态句柄的特点:
✅ 在服务器和客户端之间具有确定性的 ID
✅ 用于关卡中预放置的对象
✅ 支持延迟销毁信息的复制(Late Join 场景)
🎯 动态句柄的特点:
✅ 由服务器分配,客户端接收
✅ 用于运行时生成的对象
✅ ID 在每次游戏会话中可能不同
🎮 ReplicationSystemId 的作用
💡 新手理解:这个字段主要是给 UE 编辑器用的。当你在编辑器里点"Play"并选择多个玩家窗口时,每个窗口都是一个独立的游戏实例。
ReplicationSystemId用来区分这些实例,避免它们的句柄混淆。
ReplicationSystemId 是一个 4 位字段,用于支持 PIE(Play In Editor)多实例调试:
CPP
uint32 GetReplicationSystemId() const
{
check(ReplicationSystemId != 0);
return (uint32)(ReplicationSystemId - 1);
}值 | 含义 |
|---|---|
0 | 未设置/无效 |
1-15 | 有效的 ReplicationSystemId(实际值为 0-14) |
🎯 作用:
✅ 在 PIE 模式下区分不同的游戏实例
✅ 确保不同实例的句柄不会冲突
✅ 支持最多 15 个并行实例(4 位 - 1 个保留值)
🤔 为什么存储时要 +1?
PLAINTEXT
设计思路:用 0 表示"无效/未设置"
存储值 0 → 无效
存储值 1 → 实际 ReplicationSystemId = 0
存储值 2 → 实际 ReplicationSystemId = 1
...
存储值 15 → 实际 ReplicationSystemId = 14
这样可以用 ReplicationSystemId != 0 快速判断是否有效♻️ 句柄的生命周期管理
句柄的生命周期由 FNetRefHandleManager 管理:
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Private/Iris/ReplicationSystem/NetRefHandleManager.h
class FNetRefHandleManager
{
public:
// 分配新句柄
FNetRefHandle AllocateNetRefHandle(bool bIsStatic);
// 创建本地网络对象
FNetRefHandle CreateNetObject(FNetRefHandle WantedHandle, FNetHandle GlobalHandle,
const FReplicationProtocol* ReplicationProtocol);
// 从远程创建网络对象
FNetRefHandle CreateNetObjectFromRemote(FNetRefHandle WantedHandle,
const FReplicationProtocol* ReplicationProtocol,
FNetObjectFactoryId FactoryId);
// 销毁网络对象
void DestroyNetObject(FNetRefHandle Handle);
// 验证句柄有效性
bool IsValidNetRefHandle(FNetRefHandle Handle) const;
// 获取内部索引
FInternalNetRefIndex GetInternalIndex(FNetRefHandle Handle) const;
// 从内部索引获取句柄
FNetRefHandle GetNetRefHandleFromInternalIndex(FInternalNetRefIndex InternalIndex) const;
private:
// 下一个静态句柄索引
uint64 NextStaticHandleIndex = 1;
// 下一个动态句柄索引
uint64 NextDynamicHandleIndex = 1;
// 句柄到内部索引的映射
TMap<FNetRefHandle, FInternalNetRefIndex> RefHandleToInternalIndex;
};🎰 AllocateNetRefHandle 分配过程详解
💡 新手理解:这个函数就像是"发号机",每次调用都会生成一个新的、唯一的句柄号码。
AllocateNetRefHandle 是句柄分配的核心函数,让我们深入分析其实现:
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Private/Iris/ReplicationSystem/NetRefHandleManager.cpp
FNetRefHandle FNetRefHandleManager::AllocateNetRefHandle(bool bIsStatic){
// 根据类型选择对应的索引计数器
uint64& NextHandleId = bIsStatic ? NextStaticHandleIndex : NextDynamicHandleIndex;
// 生成新的句柄 ID
const uint64 NewHandleId = MakeNetRefHandleId(NextHandleId, bIsStatic);
FNetRefHandle NewHandle = MakeNetRefHandle(NewHandleId, ReplicationSystemId);
// 验证句柄未被使用
if (RefHandleToInternalIndex.Contains(NewHandle))
{
checkf(false, TEXT("FNetRefHandleManager::AllocateNetHandle - Handle %s already exists!"),
*NewHandle.ToString());
return FNetRefHandle();
}
// 递增索引计数器
NextHandleId = GetNextNetRefHandleId(NextHandleId);
return NewHandle;
}🔧 关键辅助函数
1️⃣ MakeNetRefHandleId - 生成句柄 ID
💡 新手理解:这个函数把"计数器值"和"静态/动态标志"组合成最终的 ID。
CPP
uint64 FNetRefHandleManager::MakeNetRefHandleId(uint64 Id, bool bIsStatic){
return (Id << 1U) | (bIsStatic ? 1U : 0U);
}这个函数将索引左移 1 位,然后在最低位设置静态/动态标志:
PLAINTEXT
为什么要左移 1 位?
因为最低位要留给"静态/动态标志"!
示例:计数器值 = 5,静态句柄
步骤1: 5 << 1 = 10
二进制: 0101 → 1010 (所有位左移,最低位空出来)
步骤2: 10 | 1 = 11
二进制: 1010 | 0001 = 1011 (最低位设为 1,表示静态)
结果: ID = 11 (二进制 1011)
└─ 最低位是 1,所以是静态句柄PLAINTEXT
示例:计数器值 = 5,动态句柄
步骤1: 5 << 1 = 10
二进制: 0101 → 1010
步骤2: 10 | 0 = 10
二进制: 1010 | 0000 = 1010 (最低位设为 0,表示动态)
结果: ID = 10 (二进制 1010)
└─ 最低位是 0,所以是动态句柄2️⃣ MakeNetRefHandle - 组装完整句柄
CPP
FNetRefHandle FNetRefHandleManager::MakeNetRefHandle(uint64 Id, uint32 ReplicationSystemId){
check((Id & FNetRefHandle::IdMask) == Id); // 确保 ID 不超过 60 位
check(ReplicationSystemId < FNetRefHandle::MaxReplicationSystemId); // 确保系统 ID 有效
FNetRefHandle Handle;
Handle.Id = Id;
Handle.ReplicationSystemId = ReplicationSystemId + 1U; // 存储时 +1,0 表示无效
return Handle;
}3️⃣ GetNextNetRefHandleId - 获取下一个索引
💡 新手理解:这个函数负责"计数器 +1",但要处理两个特殊情况:溢出回绕和跳过 0。
CPP
uint64 FNetRefHandleManager::GetNextNetRefHandleId(uint64 HandleId) const{
// 由于最低位用于静态/动态标志,实际索引只有 59 位
constexpr uint64 NetHandleIdIndexBitMask = (1ULL << (FNetRefHandle::IdBits - 1)) - 1;
// = (1ULL << 59) - 1 = 0x07FFFFFFFFFFFFFF (59 个 1)
uint64 NextHandleId = (HandleId + 1) & NetHandleIdIndexBitMask;
if (NextHandleId == 0)
{
++NextHandleId; // 跳过 0,因为 0 是无效值
}
return NextHandleId;
}🔍 为什么是 59 位而不是 60 位?
PLAINTEXT
60 位 ID 的结构:
┌────────────────────────────────────────────────────────────┬───┐
│ 索引部分 (59 bits) │S/D│
│ Bit 59 ~ Bit 1 │Bit0│
└────────────────────────────────────────────────────────────┴───┘
↑
Static/Dynamic 标志
因为 Bit 0 被静态/动态标志占用了,所以实际可用的索引位数是 59 位。
在这个函数中已经左移一位,所以实际可用的索引位数是 59 位。
uint64 FNetRefHandleManager::MakeNetRefHandleId(uint64 Id, bool bIsStatic)
{
return (Id << 1U) | (bIsStatic ? 1U : 0U);
}📊 分配流程图解
PLAINTEXT
AllocateNetRefHandle(bIsStatic = true) // 分配一个静态句柄
│
├─► 1. 选择计数器
│ └─► NextHandleId = NextStaticHandleIndex (假设当前值为 5)
│
├─► 2. 生成句柄 ID
│ └─► MakeNetRefHandleId(5, true)
│ ├─► 5 << 1 = 10 (二进制: 0...01010)
│ └─► 10 | 1 = 11 (二进制: 0...01011)
│ NewHandleId = 11
│
├─► 3. 组装完整句柄
│ └─► MakeNetRefHandle(11, ReplicationSystemId)
│ ├─► Handle.Id = 11
│ └─► Handle.ReplicationSystemId = ReplicationSystemId + 1
│
├─► 4. 验证句柄唯一性
│ └─► RefHandleToInternalIndex.Contains(NewHandle) → false
│
├─► 5. 更新计数器
│ └─► GetNextNetRefHandleId(5)
│ ├─► (5 + 1) & 0x07FFFFFFFFFFFFFF = 6
│ └─► NextStaticHandleIndex = 6
│
└─► 6. 返回新句柄
└─► NewHandle (Id=11, ReplicationSystemId=X+1)📈 静态与动态句柄的 ID 序列
由于静态和动态句柄使用独立的计数器,它们的 ID 序列如下:
计数器值 | 静态句柄 ID | 动态句柄 ID |
|---|---|---|
1 | (1 << 1) | 1 = 3 | (1 << 1) | 0 = 2 |
2 | (2 << 1) | 1 = 5 | (2 << 1) | 0 = 4 |
3 | (3 << 1) | 1 = 7 | (3 << 1) | 0 = 6 |
4 | (4 << 1) | 1 = 9 | (4 << 1) | 0 = 8 |
... | 奇数序列 | 偶数序列 |
🔍 关键观察:
🏠 静态句柄 ID 始终是奇数(最低位为 1)
🎯 动态句柄 ID 始终是偶数(最低位为 0)
✅ 两种句柄的 ID 空间完全分离,不会冲突
🎓 新手总结:这种设计非常巧妙——通过一个简单的奇偶判断,就能区分静态和动态句柄,而且两种句柄永远不会产生 ID 冲突。
🎮 完整使用场景:服务器生成敌人 Actor
⚠️ 新手提示:这个场景是理解整个 Handle 系统的关键。建议仔细阅读每一步,理解数据是如何在服务器和客户端之间流动的。
让我们通过一个实际场景来理解整个流程:
PLAINTEXT
场景:服务器在游戏中生成了一个敌人 AEnemy Actor,需要同步给所有客户端1️⃣ 第一步:服务器端 - 生成 Actor 并开始复制
CPP
// 游戏代码:服务器生成敌人
AEnemy* Enemy = GetWorld()->SpawnActor<AEnemy>(EnemyClass, SpawnLocation);
// 此时 Iris 系统会自动介入...当 Actor 被标记为需要复制时,Iris 会调用 UObjectReplicationBridge::StartReplicatingNetObject():
PLAINTEXT
服务器端流程:
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. StartReplicatingNetObject(Enemy) │
│ │ │
│ ├─► 2. AllocateNetRefHandle(bIsStatic=false) // 动态生成,所以是动态句柄 │
│ │ │ │
│ │ ├─► NextDynamicHandleIndex = 1 │
│ │ ├─► MakeNetRefHandleId(1, false) = (1 << 1) | 0 = 2 │
│ │ ├─► MakeNetRefHandle(2, ReplicationSystemId) │
│ │ └─► 返回 FNetRefHandle(Id=2, RepSysId=1) │
│ │ │
│ ├─► 3. 创建 ReplicationProtocol(描述 Enemy 有哪些属性需要复制) │
│ │ └─► 包含 Health, Position, Rotation 等属性的描述 │
│ │ │
│ ├─► 4. InternalCreateNetObject(Handle, Protocol) │
│ │ │ │
│ │ ├─► 分配 InternalIndex(内部数组索引,用于快速访问) │
│ │ ├─► 建立映射:Handle(Id=2) → InternalIndex(5) │
│ │ └─► 存储 Protocol 引用 │
│ │ │
│ └─► 5. InternalAttachInstanceToNetRefHandle(Handle, Enemy) │
│ │ │
│ ├─► 建立映射:Handle(Id=2) → Enemy* │
│ └─► 创建 InstanceProtocol(绑定 Fragment) │
│ │
│ 结果:Enemy Actor 现在有了网络身份 Handle(Id=2) │
└─────────────────────────────────────────────────────────────────────────┘2️⃣ 第二步:服务器端 - 发送数据给客户端
PLAINTEXT
服务器发送流程:
┌─────────────────────────────────────────────────────────────────────────┐
│ 复制系统每帧检查: │
│ │
│ 1. 轮询 Enemy 的属性是否变化 │
│ └─► PollReplicatedState() → 检测到 Health 从 100 变成 80 │
│ │
│ 2. 序列化变化的数据 │
│ ├─► 写入 Handle Id: 2 │
│ ├─► 写入 ChangeMask: 0b001 (只有 Health 变了) │
│ └─► 写入 Health 值: 80 │
│ │
│ 3. 通过网络发送给客户端 │
│ └─► 发送数据包: [Handle=2, ChangeMask=0b001, Health=80] │
└─────────────────────────────────────────────────────────────────────────┘3️⃣ 第三步:客户端 - 接收并创建对象
PLAINTEXT
客户端接收流程(首次收到 Handle=2 的数据):
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. 收到数据包,发现 Handle=2 是新对象 │
│ │
│ 2. OnInstantiateFromRemote() - 创建本地对象 │
│ │ │
│ ├─► 根据 FactoryId 找到工厂类 │
│ ├─► 工厂创建 AEnemy 实例(客户端的内存地址可能是 0x7FFF56780000) │
│ └─► 返回 Enemy* │
│ │
│ 3. InternalCreateNetObjectFromRemote(WantedHandle=2, Protocol) │
│ │ │
│ ├─► 使用服务器指定的 Handle Id(不是自己分配!) │
│ ├─► 分配本地 InternalIndex │
│ └─► 建立映射:Handle(Id=2) → InternalIndex → Enemy* │
│ │
│ 4. ApplyReplicatedState() - 应用接收到的状态 │
│ └─► Enemy->Health = 80 │
│ │
│ 结果:客户端的 Enemy 和服务器的 Enemy 通过 Handle(Id=2) 建立了对应关系 │
└─────────────────────────────────────────────────────────────────────────┘4️⃣ 第四步:后续同步
PLAINTEXT
后续同步流程:
┌─────────────────────────────────────────────────────────────────────────┐
│ 服务器:Enemy->Health = 50 │
│ │ │
│ ├─► 检测到变化 │
│ └─► 发送: [Handle=2, Health=50] │
│ │
│ 客户端:收到 [Handle=2, Health=50] │
│ │ │
│ ├─► 通过 Handle=2 查找本地 Enemy* │
│ │ └─► RefHandleToInternalIndex[Handle=2] → InternalIndex │
│ │ └─► InternalIndex → Enemy* │
│ │ │
│ └─► Enemy->Health = 50 │
└─────────────────────────────────────────────────────────────────────────┘5️⃣ 第五步:销毁对象
PLAINTEXT
销毁流程:
┌─────────────────────────────────────────────────────────────────────────┐
│ 服务器:Enemy 被销毁 │
│ │ │
│ ├─► DestroyNetObject(Handle=2) │
│ │ ├─► 从映射表移除 Handle=2 │
│ │ ├─► 释放 InternalIndex │
│ │ └─► 发送销毁通知给客户端 │
│ │ │
│ 客户端:收到销毁通知 │
│ │ │
│ ├─► DetachInstanceFromRemote(Handle=2) │
│ │ ├─► 从映射表移除 Handle=2 │
│ │ └─► 销毁本地 Enemy Actor │
└─────────────────────────────────────────────────────────────────────────┘📋 关键点总结
❓ 问题 | ✅ 答案 |
|---|---|
谁分配 Handle? | 🖥️ 服务器分配(对于动态对象),客户端使用服务器给的 Handle |
Handle 存在哪里? | 📁 |
如何通过 Handle 找到对象? | 🔍 Handle → InternalIndex → 对象指针 |
静态 vs 动态有什么区别? | 🏠 静态对象(关卡预放置)两端 Handle 相同;🎯 动态对象由服务器分配 |
InternalIndex 是什么? | ⚡ 内部数组索引,用于快速访问对象数据,避免 Map 查找开销 |
📝 2.2 ReplicationState(复制状态)
📌 概述
FReplicationStateDescriptor 是 Iris 中描述复制状态结构的核心数据类型。它定义了一个复制状态包含哪些成员、如何序列化、如何检测变化等所有必要信息。
💡 新手理解:如果说
FNetRefHandle是对象的"身份证",那么FReplicationStateDescriptor就是对象的"体检报告模板"——它详细记录了这个对象有哪些属性需要检查(同步),每个属性在哪里,用什么方式检查。
❓ 为什么需要 ReplicationStateDescriptor?
想象一下,你有一个 AEnemy 类:
CPP
UCLASS()
class AEnemy : public AActor
{
UPROPERTY(Replicated)
float Health;
UPROPERTY(Replicated)
FVector Position;
UPROPERTY(Replicated)
FRotator Rotation;
// 不复制的属性
float LocalTimer;
};❓ 问题:Iris 系统需要知道:
这个类有哪些属性需要复制?(Health, Position, Rotation)
每个属性在内存中的位置(偏移量)是多少?
每个属性用什么方式序列化?(float 用 FloatSerializer,FVector 用 VectorSerializer)
如何检测哪个属性变了?
✅ 答案:FReplicationStateDescriptor 就是存储这些"元数据"的结构。
🏥 新手比喻:就像你去医院体检,医生不会随便检查,而是按照"体检套餐"来。套餐里写明了:
📋 要检查哪些项目(血压、心率、血糖...)
🔬 每个项目用什么仪器检查
📍 检查结果记录在哪个位置
ReplicationStateDescriptor就是 Iris 的"体检套餐说明书"。
PLAINTEXT
FReplicationStateDescriptor 的作用:┌─────────────────────────────────────────────────────────────────────────┐│ AEnemy 的 ReplicationStateDescriptor │├─────────────────────────────────────────────────────────────────────────┤│ MemberCount = 3 ← 有 3 个属性需要复制 ││ ││ MemberDescriptors[] = [ ← 每个属性在内存中的位置 ││ { ExternalOffset: 64, InternalOffset: 0 }, // Health ││ { ExternalOffset: 68, InternalOffset: 4 }, // Position ││ { ExternalOffset: 80, InternalOffset: 16 }, // Rotation ││ ] ││ ││ MemberSerializerDescriptors[] = [ ← 每个属性用什么方式序列化 ││ { Serializer: FloatNetSerializer }, // Health ││ { Serializer: VectorNetSerializer }, // Position ││ { Serializer: RotatorNetSerializer }, // Rotation ││ ] ││ ││ MemberChangeMaskDescriptors[] = [ ← 每个属性的变化标记位置 ││ { BitOffset: 0, BitCount: 1 }, // Health 变化用 bit 0 表示 ││ { BitOffset: 1, BitCount: 1 }, // Position 变化用 bit 1 表示 ││ { BitOffset: 2, BitCount: 1 }, // Rotation 变化用 bit 2 表示 ││ ] ││ ││ ChangeMaskBitCount = 3 // 总共需要 3 个 bit 来表示变化 │└─────────────────────────────────────────────────────────────────────────┘🔄 实际使用流程:检测和发送变化
⚠️ 新手提示:这是理解 Iris 如何高效同步数据的关键。注意观察 ChangeMask 是如何避免发送未变化的数据的。
PLAINTEXT
场景:Enemy 的 Health 从 100 变成 80(Position 和 Rotation 没变)
┌─────────────────────────────────────────────────────────────────────────┐
│ 传统做法(低效): │
│ 每次都发送所有属性:[Health=80, Position=(1,2,3), Rotation=(0,90,0)] │
│ 问题:Position 和 Rotation 没变,但还是发送了,浪费带宽! │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Iris 的做法(高效): │
│ 只发送变化的属性:[ChangeMask=0b001, Health=80] │
│ 优势:Position 和 Rotation 没变,就不发送,节省带宽! │
└─────────────────────────────────────────────────────────────────────────┘📊 三个阶段的详细流程:
PLAINTEXT
1. 轮询阶段 (Poll)
┌─────────────────────────────────────────────────────────────────────┐
│ 系统遍历 MemberDescriptors: │
│ │
│ for (int i = 0; i < MemberCount; i++) { │
│ // 获取属性在对象中的偏移 │
│ uint32 offset = MemberDescriptors[i].ExternalOffset; │
│ // 读取当前值 │
│ void* currentValue = (uint8*)Enemy + offset; │
│ // 与上次发送的值比较 │
│ if (currentValue != lastSentValue[i]) { │
│ // 设置变化掩码的对应位 │
│ ChangeMask |= (1 << MemberChangeMaskDescriptors[i].BitOffset);│
│ } │
│ } │
│ │
│ 结果:ChangeMask = 0b001 (只有 bit 0 被设置,表示 Health 变了) │
└─────────────────────────────────────────────────────────────────────┘
2. 序列化阶段 (Serialize)
┌─────────────────────────────────────────────────────────────────────┐
│ // 只序列化变化的属性 │
│ for (int i = 0; i < MemberCount; i++) { │
│ if (ChangeMask & (1 << i)) { // 这个属性变了吗? │
│ // 获取序列化器 │
│ FNetSerializer* serializer = MemberSerializerDescriptors[i]; │
│ // 序列化数据 │
│ serializer->Serialize(bitStream, currentValue); │
│ } │
│ } │
│ │
│ 发送的数据:[Handle=2, ChangeMask=0b001, Health=80] │
│ 注意:Position 和 Rotation 没变,所以不发送,节省带宽! │
└─────────────────────────────────────────────────────────────────────┘
3. 反序列化阶段 (Deserialize) - 客户端
┌─────────────────────────────────────────────────────────────────────┐
│ // 根据 ChangeMask 只反序列化收到的属性 │
│ for (int i = 0; i < MemberCount; i++) { │
│ if (ChangeMask & (1 << i)) { │
│ FNetSerializer* serializer = MemberSerializerDescriptors[i]; │
│ serializer->Deserialize(bitStream, &receivedValue); │
│ // 应用到对象 │
│ uint32 offset = MemberDescriptors[i].ExternalOffset; │
│ memcpy((uint8*)Enemy + offset, &receivedValue, size); │
│ } │
│ } │
└─────────────────────────────────────────────────────────────────────┘🔍 FReplicationStateDescriptor 结构详解
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationState/ReplicationStateDescriptor.h
struct FReplicationStateDescriptor
{
// ===== 引用计数 =====
void AddRef() const;
void Release() const;
int32 GetRefCount() const { return RefCount; }
// ===== 状态查询 =====
bool IsInitState() const { return EnumHasAnyFlags(Traits, EReplicationStateTraits::InitOnly); }
bool HasObjectReference() const { return EnumHasAnyFlags(Traits, EReplicationStateTraits::HasObjectReference); }
uint32 GetChangeMaskOffset() const { return ChangeMasksExternalOffset; }
uint32 GetConditionalChangeMaskOffset() const;
// ===== 成员描述符数组 =====
const FReplicationStateMemberDescriptor* MemberDescriptors; // 成员偏移描述
const FReplicationStateMemberChangeMaskDescriptor* MemberChangeMaskDescriptors; // 变化掩码描述
const FReplicationStateMemberSerializerDescriptor* MemberSerializerDescriptors; // 序列化器描述
const FReplicationStateMemberTraitsDescriptor* MemberTraitsDescriptors; // 成员特性描述
const FReplicationStateMemberFunctionDescriptor* MemberFunctionDescriptors; // 函数描述
const FReplicationStateMemberTagDescriptor* MemberTagDescriptors; // 标签描述
const FReplicationStateMemberReferenceDescriptor* MemberReferenceDescriptors; // 引用描述
const FProperty** MemberProperties; // UProperty 指针
const FReplicationStateMemberPropertyDescriptor* MemberPropertyDescriptors; // 属性描述
const FReplicationStateMemberLifetimeConditionDescriptor* MemberLifetimeConditionDescriptors; // 生命周期条件
const FReplicationStateMemberRepIndexToMemberIndexDescriptor* MemberRepIndexToMemberIndexDescriptors; // RepIndex 映射
const UScriptStruct* BaseStruct; // 派生结构体的基类
// ===== 调试信息 =====
const FNetDebugName* DebugName;
const FReplicationStateMemberDebugDescriptor* MemberDebugDescriptors;
// ===== 大小和对齐 =====
uint32 ExternalSize; // 外部表示的大小(游戏对象中的布局)
uint32 InternalSize; // 内部表示的大小(复制系统内部缓冲区)
uint16 ExternalAlignment; // 外部对齐
uint16 InternalAlignment; // 内部对齐
// ===== 计数信息 =====
uint16 MemberCount; // 成员数量
uint16 FunctionCount; // 函数数量
uint16 TagCount; // 标签数量
uint16 ObjectReferenceCount; // 对象引用数量
uint16 RepIndexCount; // RepIndex 数量
uint16 ChangeMaskBitCount; // 变化掩码位数
uint32 ChangeMasksExternalOffset; // 变化掩码在外部状态中的偏移
// ===== 标识符 =====
FReplicationStateIdentifier DescriptorIdentifier; // 状态唯一标识(用于协议匹配)
// ===== 构造/析构函数 =====
ConstructReplicationStateFunc ConstructReplicationState;
DestructReplicationStateFunc DestructReplicationState;
CreateAndRegisterReplicationFragmentFunc CreateAndRegisterReplicationFragmentFunction;
// ===== 特性标志 =====
EReplicationStateTraits Traits;
// ===== 引用计数 =====
mutable std::atomic<int32> RefCount;
// ===== 默认状态 =====
const uint8* DefaultStateBuffer; // 默认状态缓冲区
};📍 MemberDescriptors 成员描述符
💡 新手理解:这个结构告诉 Iris "属性在哪里"。就像快递员需要知道"包裹在仓库的哪个货架上"。
FReplicationStateMemberDescriptor 描述每个成员在外部和内部表示中的偏移:
CPP
struct FReplicationStateMemberDescriptor
{
uint32 ExternalMemberOffset; // 在游戏对象中的偏移
uint32 InternalMemberOffset; // 在复制系统内部缓冲区中的偏移
};🔍 为什么有两个偏移?
PLAINTEXT
问题:游戏对象的内存布局可能很复杂(有虚函数表、继承的成员等)
但复制系统只关心需要复制的属性
解决方案:
- ExternalOffset:属性在游戏对象中的实际位置(可能很分散)
- InternalOffset:属性在复制系统缓冲区中的位置(紧凑排列)
这样复制系统可以用自己的紧凑缓冲区来存储和比较数据。📊 示意图:
PLAINTEXT
游戏对象 (External) 复制系统缓冲区 (Internal)
┌─────────────────────┐ ┌─────────────────────┐
│ VTable 指针 │ │ ChangeMask │ ← 额外的控制信息
├─────────────────────┤ ├─────────────────────┤
│ 继承的成员... │ │ Health (offset: 4) │ ← 紧凑排列
├─────────────────────┤ ├─────────────────────┤
│ Health (offset: 64) │ ◄──────────► │ Position (offset: 8)│
├─────────────────────┤ ├─────────────────────┤
│ Position (offset: 68)│ ◄──────────► │ Rotation (offset: 20)│
├─────────────────────┤ └─────────────────────┘
│ Rotation (offset: 80)│ ◄──────────► 紧凑!没有空隙
├─────────────────────┤
│ LocalTimer (不复制) │
└─────────────────────┘
可能有空隙🔄 MemberSerializerDescriptors 序列化器描述符
💡 新手理解:序列化器就是"翻译官" 🗣️,负责把内存中的数据转换成可以通过网络传输的格式,以及反过来的转换。
FReplicationStateMemberSerializerDescriptor 指定每个成员使用的序列化器:
CPP
struct FReplicationStateMemberSerializerDescriptor
{
const FNetSerializer* Serializer; // 序列化器指针
const FNetSerializerConfig* SerializerConfig; // 序列化器配置
};🤔 为什么需要不同的序列化器?
PLAINTEXT
不同类型的数据,需要不同的处理方式:
float Health = 80.5f;
├─► FloatNetSerializer
│ └─► 可能会量化(比如只保留整数部分),节省带宽
FVector Position = (1000.0f, 2000.0f, 50.0f);
├─► VectorNetSerializer
│ └─► 可能会压缩(比如用 16 位而不是 32 位存储每个分量)
UObject* Target = SomeActor;
├─► ObjectNetSerializer
│ └─► 不能直接发送指针!要转换成 FNetRefHandle 发送📦 常见序列化器类型:
序列化器 | 用途 | 说明 |
|---|---|---|
🔢 | 整数类型 | 可配置位数 |
📊 | 浮点数类型 | 可配置精度 |
📍 | FVector 类型 | 可配置量化 |
🔄 | FRotator 类型 | 可配置量化 |
🎯 | UObject 引用 | 转换为 Handle |
📝 | FString 类型 | 支持压缩 |
✅ MemberChangeMaskDescriptors 变化掩码
💡 新手理解:变化掩码就像一个"签到表" 📋,用一个 bit 表示一个属性是否变化了。这样只需要很少的数据就能告诉对方"哪些属性变了"。
FReplicationStateMemberChangeMaskDescriptor 定义每个成员在变化掩码中的位置:
CPP
struct FReplicationStateMemberChangeMaskDescriptor
{
uint16 BitOffset; // 在变化掩码中的位偏移
uint16 BitCount; // 占用的位数(通常为 1,数组可能更多)
};📊 变化掩码的工作原理:
PLAINTEXT
假设 AEnemy 有 3 个复制属性:Health, Position, Rotation
ChangeMask 是一个整数,每个 bit 对应一个属性:
┌───────┬───────┬───────┐
│ R │ P │ H │ ← 属性
│bit2 │bit1 │bit0 │ ← 位置
└───────┴───────┴───────┘
场景1:只有 Health 变了
ChangeMask = 0b001 (十进制 1)
↑
└─ bit0=1(Health 变了)
场景2:Health 和 Rotation 都变了
ChangeMask = 0b101 (十进制 5)
↑ ↑
│ └─ bit0=1(Health 变了)
└─── bit2=1(Rotation 变了)
场景3:所有属性都变了
ChangeMask = 0b111 (十进制 7)
↑↑↑
││└─ bit0(Health)
│└── bit1(Position)
└─── bit2(Rotation)📊 变化掩码示意(32 bits 示例):
PLAINTEXT
ChangeMask (32 bits example)
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│1│1│0│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
31 2 1 0
↑ ↑ ↑
│ │ └─ bit0: Health (clean)
│ └─── bit1: Position (dirty)
└───── bit2: Rotation (dirty)
对应前面的例子:
- bit0 = Health
- bit1 = Position
- bit2 = Rotation
上图 ChangeMask = 0b110 表示 Position 和 Rotation 变了,Health 没变💡 为什么这样设计高效?
PLAINTEXT
传统方式:发送 [属性1变了吗=true, 属性2变了吗=false, 属性3变了吗=true]
需要 3 个 bool = 3 字节
ChangeMask:发送 0b101 = 1 字节(甚至可以更少)
当属性很多时(比如 32 个),节省更明显:
传统方式:32 字节
ChangeMask:4 字节🤔 "甚至可以更少"是怎么做到的?
PLAINTEXT
关键技术:位流(BitStream)传输
┌─────────────────────────────────────────────────────────────────┐
│ 传统字节流(ByteStream): │
│ 最小单位是 1 字节(8 位) │
│ 即使只需要 3 位,也要占用 1 字节 │
│ │
│ ┌────────┬────────┬────────┐ │
│ │ Byte 0 │ Byte 1 │ Byte 2 │ ← 每个数据至少占 1 字节 │
│ └────────┴────────┴────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Iris 位流(BitStream): │
│ 最小单位是 1 位(bit) │
│ 3 个属性的 ChangeMask 只需要 3 位 │
│ │
│ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │
│ │1│0│1│ Handle │ Health 值 │...│ ← 数据紧密排列,共享字节 │
│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │
│ ↑↑↑ │
│ ChangeMask 只占 3 位,不需要补齐到 8 位 │
└─────────────────────────────────────────────────────────────────┘
示例:发送 ChangeMask = 0b101(3 个属性,2 个变了)
字节流方式:
ChangeMask: 00000101 ← 占用 8 位(1 字节),高 5 位浪费了
位流方式:
ChangeMask: 101 ← 只占用 3 位
后面紧跟其他数据,共享同一个字节的剩余 5 位
节省:(8 - 3) / 8 = 62.5% 的空间!方式 | ChangeMask 占用 | 说明 |
|---|---|---|
❌ 字节流 | 8 位(1 字节) | 必须补齐到字节边界 |
✅ 位流 | 3 位 | 只用实际需要的位数 |
🎓 新手总结:Iris 使用位流传输,ChangeMask 不需要占满整个字节,可以和其他数据"挤在一起",从而实现比 1 字节更少的传输量。
🏷️ EReplicationStateTraits 特性标志
💡 新手理解:这些标志告诉 Iris "这个状态有什么特殊需求"。就像快递包裹上的标签:📦 "易碎"、❄️ "冷链"、🚀 "加急"等。
CPP
enum class EReplicationStateTraits : uint32
{
None = 0U,
InitOnly = 1U, // 仅初始化时复制
HasLifetimeConditionals = 1U << 1, // 有生命周期条件
HasObjectReference = 1U << 2, // 包含对象引用
NeedsRefCount = 1U << 3, // 需要引用计数
HasRepNotifies = 1U << 4, // 有 RepNotify
KeepPreviousState = 1U << 5, // 保留前一状态
HasDynamicState = 1U << 6, // 有动态状态
IsSourceTriviallyConstructible = 1U << 7, // 源类型可平凡构造
IsSourceTriviallyDestructible = 1U << 8, // 源类型可平凡析构
AllMembersAreReplicated = 1U << 9, // 所有成员都复制
IsFastArrayReplicationState = 1U << 10, // 是 FastArray 状态
IsNativeFastArrayReplicationState = 1U << 11, // 是原生 FastArray 状态
HasConnectionSpecificSerialization = 1U << 12, // 有连接特定序列化
HasPushBasedDirtiness = 1U << 13, // 支持 Push Model
SupportsDeltaCompression = 1U << 14, // 支持增量压缩
UseSerializerIsEqual = 1U << 15, // 使用序列化器的 IsEqual
IsDerivedStruct = 1U << 16, // 是派生结构体
};📋 常用特性标志解释:
特性 | 作用 | 使用场景 |
|---|---|---|
🔒 | 只在对象初始化时复制一次 | 角色名字、初始配置等不会变的数据 |
🎯 | 启用条件复制 |
|
🔗 | 需要处理对象引用解析 | 属性引用了其他 Actor |
📢 | 需要调用 RepNotify 回调 | 属性变化时需要执行逻辑 |
📤 | 使用 Push Model 检测脏数据 | 性能优化,避免每帧轮询 |
📦 | 可以使用增量压缩 | 大数据量的属性 |
⚡ | 可用 memcpy 复制,跳过构造函数 | 简单 POD 类型 |
⚡ | 可直接释放内存,跳过析构函数 | 简单 POD 类型 |
🤔 什么是平凡构造/析构(Trivially Constructible/Destructible)?
💡 新手理解:平凡(Trivial) 意味着编译器可以用最简单的方式处理,不需要执行任何自定义代码。
PLAINTEXT
平凡构造(Trivially Constructible)
────────────────────────────────────────────────────────
含义:创建对象时,不需要调用构造函数,直接分配内存即可
✓ 平凡构造的类型:
- int, float, bool 等基本类型
- 没有构造函数的简单 struct
- 只包含平凡类型成员的 struct
✗ 非平凡构造的类型:
- 有自定义构造函数的类
- 包含 FString、TArray 等复杂成员的类
- 有虚函数的类(需要初始化虚表指针)
平凡析构(Trivially Destructible)
────────────────────────────────────────────────────────
含义:销毁对象时,不需要调用析构函数,直接释放内存即可
✓ 平凡析构的类型:
- int, float, bool 等基本类型
- 没有析构函数的简单 struct
✗ 非平凡析构的类型:
- 有自定义析构函数的类
- 包含需要释放资源的成员(FString、TArray、指针等)💻 代码示例:
CPP
// ✓ 平凡构造 + 平凡析构struct FSimpleData
{
float Health;
int32 Score;
bool bIsAlive;
};
// 创建:直接 memset 或分配内存即可// 销毁:直接释放内存即可
// ✗ 非平凡构造 + 非平凡析构struct FComplexData
{
FString Name; // FString 有构造函数,需要初始化
TArray<int32> Items; // TArray 有构造函数
FComplexData() { Name = TEXT("Default"); } // 自定义构造
~FComplexData() { Items.Empty(); } // 自定义析构
};
// 创建:必须调用构造函数// 销毁:必须调用析构函数释放 FString 和 TArray 的内存⚡ 在 Iris 中的性能优化作用:
PLAINTEXT
性能对比:
✅ 平凡类型:复制 1000 个对象 → 1 次 memcpy(微秒级)
❌ 非平凡类型:复制 1000 个对象 → 1000 次构造函数调用(毫秒级)🎓 新手总结:平凡 = 简单到可以直接用
memcpy复制、直接释放内存,不需要调用任何函数。Iris 用这两个标志来决定是否可以使用更快的内存操作,从而优化性能。
💡 新手提示:你不需要手动设置这些标志,Iris 会根据你的 UPROPERTY 宏自动分析并设置。
🔧 成员特性描述符
CPP
enum class EReplicationStateMemberTraits : uint16
{
None = 0U,
HasDynamicState = 1U << 0, // 成员有动态状态
HasObjectReference = 1U << 1, // 成员包含对象引用
HasConnectionSpecificSerialization = 1U << 2, // 连接特定序列化
HasRepNotifyAlways = 1U << 3, // 总是触发 RepNotify
UseSerializerIsEqual = 1U << 4, // 使用序列化器比较
};
struct FReplicationStateMemberTraitsDescriptor
{
EReplicationStateMemberTraits Traits;
};🔗 对象引用描述符
CPP
struct FNetReferenceInfo
{
enum EResolveType : uint8
{
Invalid = 0U, // 无效,用于动态内存中的引用
ResolveOnClient, // 在客户端解析(默认)
MustExistOnClient, // 必须在客户端确认后才复制
ResolveOnlyWhenRecvd, // 仅在接收时解析,否则设为 nullptr
};
EResolveType ResolveType;
uint8 Padding;
};
struct FReplicationStateMemberReferenceDescriptor
{
uint32 Offset; // 引用在状态中的偏移
FNetReferenceInfo Info; // 引用信息
uint16 MemberIndex; // 成员索引
uint16 InnerReferenceIndex; // 嵌套引用索引(~0 表示无效)
};🚚 2.3 ReplicationFragment(复制片段)
📌 概述
FReplicationFragment 是连接游戏对象和复制状态的桥梁。它负责:
📤 从游戏对象提取状态数据(Poll)
📥 将接收到的状态应用到游戏对象(Apply)
📢 调用 RepNotify 回调
💡 新手理解:如果把网络复制比作快递系统:
🏷️
FNetRefHandle是快递单号📝
FReplicationStateDescriptor是包裹清单🚚
FReplicationFragment就是快递员——负责取件(从对象读取数据)和派送(把数据写入对象)
❓ 为什么需要 Fragment?
前面我们了解了:
FNetRefHandle:标识"哪个对象"FReplicationStateDescriptor:描述"有哪些属性、怎么序列化"
但还缺少一个关键环节:谁来执行实际的读写操作?
PLAINTEXT
问题:
┌─────────────────────────────────────────────────────────────────────────┐
│ Descriptor 只是"说明书",告诉你 Health 在偏移 64 的位置 │
│ 但谁来真正执行: │
│ - 从 Enemy 对象读取 Health 值? │
│ - 把收到的 Health 值写入 Enemy 对象? │
│ - 检测 Health 是否变化? │
│ - 调用 OnRep_Health() 回调? │
└─────────────────────────────────────────────────────────────────────────┘
答案:Fragment!
┌─────────────────────────────────────────────────────────────────────────┐
│ FReplicationFragment │
│ │
│ ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ 游戏对象 │ ←──► │ Fragment │ ←──► │ 复制系统 │ │
│ │ (AEnemy) │ │ (读写桥梁) │ │ (网络传输) │ │
│ └─────────────┘ └─────────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ 持有 Descriptor │
│ 知道怎么读写 │
└─────────────────────────────────────────────────────────────────────────┘🔄 Fragment 的实际工作流程
PLAINTEXT
场景:服务器端 Enemy 的 Health 从 100 变成 80
┌─────────────────────────────────────────────────────────────────────────┐
│ 服务器端 - Poll(轮询检测变化) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 复制系统调用:Fragment->PollReplicatedState() │
│ │
│ 2. Fragment 执行: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ // 获取 Enemy 对象的当前状态 │ │
│ │ float currentHealth = *(float*)((uint8*)Owner + HealthOffset); │ │
│ │ // currentHealth = 80 │ │
│ │ │ │
│ │ // 与上次保存的状态比较 │ │
│ │ float lastHealth = SrcReplicationState->Health; │ │
│ │ // lastHealth = 100 │ │
│ │ │ │
│ │ // 发现变化! │ │
│ │ if (currentHealth != lastHealth) { │ │
│ │ ChangeMask |= (1 << HealthBitOffset); // 标记 Health 脏 │ │
│ │ SrcReplicationState->Health = currentHealth; // 更新缓存 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. 返回 true(有脏数据) │
│ │
│ 4. 复制系统根据 ChangeMask 序列化并发送数据 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 客户端 - Apply(应用接收到的状态) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 复制系统收到数据:[Handle=2, ChangeMask=0b001, Health=80] │
│ │
│ 2. 复制系统调用:Fragment->ApplyReplicatedState(Context) │
│ │
│ 3. Fragment 执行: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ // 从 Context 获取反序列化后的数据 │ │
│ │ float receivedHealth = Context.StateBuffer->Health; // = 80 │ │
│ │ │ │
│ │ // 写入到游戏对象 │ │
│ │ *(float*)((uint8*)Owner + HealthOffset) = receivedHealth; │ │
│ │ // 现在 Enemy->Health = 80 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. 如果有 RepNotify,调用 Fragment->CallRepNotifies() │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ // 检查 Health 是否有 RepNotify │ │
│ │ if (HasRepNotifyForHealth) { │ │
│ │ Owner->OnRep_Health(); // 调用回调函数 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘🔗 Fragment 与其他组件的关系
PLAINTEXT
┌─────────────────────────────────────────────────────────────────────────┐│ 完整关系图 │├─────────────────────────────────────────────────────────────────────────┤│ ││ AEnemy (游戏对象) ││ ┌─────────────────────┐ ││ │ Health = 80 │ ││ │ Position = (1,2,3) │ ││ │ Rotation = (0,90,0) │ ││ └──────────┬──────────┘ ││ │ ││ │ Owner 指针 ││ ▼ ││ FPropertyReplicationFragment ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ Owner: AEnemy* ← 指向游戏对象 │ ││ │ ReplicationStateDescriptor: Descriptor* ← 知道怎么读写 │ ││ │ SrcReplicationState: 缓存的状态 ← 用于比较检测变化 │ ││ │ PrevReplicationState: 上一次的状态 ← 用于 RepNotify │ ││ │ │ ││ │ 方法: │ ││ │ PollReplicatedState() → 检测变化,更新 ChangeMask │ ││ │ ApplyReplicatedState() → 把收到的数据写入 Owner │ ││ │ CallRepNotifies() → 调用 OnRep_XXX 回调 │ ││ └─────────────────────────────────────────────────────────────────┘ ││ │ ││ │ 注册到 ││ ▼ ││ FReplicationInstanceProtocol ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ Fragments[]: 这个对象的所有 Fragment │ ││ │ FragmentData[]: 每个 Fragment 的数据 │ ││ └─────────────────────────────────────────────────────────────────┘ ││ │ ││ │ 关联到 ││ ▼ ││ FNetRefHandle (Id=2) ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ 网络标识符,用于服务器和客户端识别同一个对象 │ ││ └─────────────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────────────┘⚙️ Fragment 的作用与职责
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationSystem/ReplicationFragment.h
class FReplicationFragment
{
public:
explicit FReplicationFragment(EReplicationFragmentTraits InTraits) : Traits(InTraits) {}
virtual ~FReplicationFragment() {}
// 获取特性
EReplicationFragmentTraits GetTraits() const { return Traits; }
// ===== 核心接口 =====
// 应用接收到的复制状态
virtual void ApplyReplicatedState(FReplicationStateApplyContext& Context) const = 0;
// 收集所有者(用于 Pre/PostNetReceive 回调)
virtual void CollectOwner(FReplicationStateOwnerCollector* Owners) const {}
// 调用 RepNotify
virtual void CallRepNotifies(FReplicationStateApplyContext& Context) {}
// 轮询状态变化(返回 true 表示脏)
virtual bool PollReplicatedState(EReplicationFragmentPollFlags PollOption) { return false; }
// 输出状态到字符串(调试用)
virtual void ReplicatedStateToString(FStringBuilderBase& StringBuilder,
FReplicationStateApplyContext& Context,
EReplicationStateToStringFlags Flags) const {}
protected:
EReplicationFragmentTraits Traits;
};🏷️ EReplicationFragmentTraits 特性
💡 新手理解:这些标志告诉系统这个 Fragment "能做什么"和"需要什么"。
CPP
enum class EReplicationFragmentTraits : uint32
{
None = 0,
HasInterpolation = 1, // 支持插值(未实现)
HasRepNotifies = 1 << 1, // 有 RepNotify
KeepPreviousState = 1 << 2, // 保留前一状态
DeleteWithInstanceProtocol = 1 << 3, // 随实例协议销毁
HasPersistentTargetStateBuffer = 1 << 4, // 有持久目标状态缓冲
CanReplicate = 1 << 5, // 可以发送复制数据
CanReceive = 1 << 6, // 可以接收复制数据
NeedsPoll = 1 << 7, // 需要轮询检测脏数据
NeedsLegacyCallbacks = 1 << 8, // 需要旧版回调
NeedsPreSendUpdate = 1 << 9, // 需要 PreSendUpdate
NeedsWorldLocationUpdate = 1 << 10, // 需要更新世界位置
HasPushBasedDirtiness = 1 << 11, // 支持 Push Model
HasPropertyReplicationState = 1 << 12, // 使用属性复制状态
HasObjectReference = 1 << 13, // 有对象引用
SupportsPartialDequantizedState = 1 << 14, // 支持部分反量化
};📋 关键特性解释:
特性 | 作用 | 通俗解释 |
|---|---|---|
📤 | 可以发送数据 | 这个 Fragment 是"发货方" |
📥 | 可以接收数据 | 这个 Fragment 是"收货方" |
🔍 | 需要轮询检测 | 需要定期检查属性是否变化 |
📢 | 有回调函数 | 属性变化时需要通知游戏逻辑 |
⚡ | 支持 Push Model | 属性变化时主动通知,而不是被动轮询 |
🔑 与游戏对象的绑定关系
Fragment 通过 FFragmentRegistrationContext 注册:
CPP
class FFragmentRegistrationContext
{
public:
// 注册 Fragment
void RegisterReplicationFragment(FReplicationFragment* Fragment,
const FReplicationStateDescriptor* Descriptor,
void* SrcReplicationStateBuffer);
// 标记为无 Fragment 的网络对象
void SetIsFragmentlessNetObject(bool bIsFragmentless);
// 查询
bool IsFragmentlessNetObject() const;
bool WasRegistered() const;
int32 NumFragments() const;
private:
FReplicationFragments Fragments;
Private::FReplicationStateDescriptorRegistry* ReplicationStateRegistry;
UReplicationSystem* ReplicationSystem;
const EReplicationFragmentTraits FragmentTraits;
bool bIsAFragmentlessNetObject = false;
};📝 注册流程:
PLAINTEXT
1. 创建 Fragment
│
├─► FPropertyReplicationFragment::CreateAndRegisterFragment()
│ 或
├─► TFastArrayReplicationFragment 构造
│
▼
2. 注册到 Context
│
├─► Context.RegisterReplicationFragment(Fragment, Descriptor, SrcBuffer)
│ ├─► 记录 Descriptor
│ ├─► 记录源状态缓冲区指针
│ └─► 添加到 Fragments 数组
│
▼
3. 创建 InstanceProtocol
│
└─► 系统根据注册的 Fragments 创建 FReplicationInstanceProtocol📦 Fragment 类型
💡 新手理解:不同类型的数据需要不同的 Fragment 来处理。就像快递公司有普通快递员、冷链快递员、大件快递员一样。
1️⃣ FPropertyReplicationFragment
用途:处理标准的 UPROPERTY 复制(最常用)✨
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationSystem/PropertyReplicationFragment.h
class FPropertyReplicationFragment : public FReplicationFragment
{
public:
FPropertyReplicationFragment(EReplicationFragmentTraits InTraits,
UObject* InOwner,
const FReplicationStateDescriptor* InDescriptor);
~FPropertyReplicationFragment();
// 获取属性复制状态
const FPropertyReplicationState* GetPropertyReplicationState() const;
// 注册已存在的 Fragment
void Register(FFragmentRegistrationContext& Fragments);
// 创建并注册(生命周期由系统管理)
static FPropertyReplicationFragment* CreateAndRegisterFragment(
UObject* InOwner,
const FReplicationStateDescriptor* InDescriptor,
FFragmentRegistrationContext& Context);
protected:
virtual void ApplyReplicatedState(FReplicationStateApplyContext& Context) const override;
virtual void CollectOwner(FReplicationStateOwnerCollector* Owners) const override;
virtual void CallRepNotifies(FReplicationStateApplyContext& Context) override;
virtual bool PollReplicatedState(EReplicationFragmentPollFlags PollOption) override;
private:
TUniquePtr<FPropertyReplicationState> SrcReplicationState; // 源状态(用于比较)
TUniquePtr<FPropertyReplicationState> PrevReplicationState; // 前一状态(用于 RepNotify)
TRefCountPtr<const FReplicationStateDescriptor> ReplicationStateDescriptor;
UObject* Owner; // 指向游戏对象
};🎮 使用场景:
CPP
UCLASS()
class AMyActor : public AActor
{
UPROPERTY(Replicated)
float Health; // ← 用 FPropertyReplicationFragment 处理
UPROPERTY(Replicated)
FVector Position; // ← 用 FPropertyReplicationFragment 处理
};2️⃣ TFastArrayReplicationFragment
用途:处理 FastArray 复制(用于需要增量更新的数组)⚡
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationSystem/FastArrayReplicationFragment.h
template <typename FastArrayItemType, typename FastArrayType>
class TFastArrayReplicationFragment : public Private::FFastArrayReplicationFragmentBase
{
public:
typedef TArray<FastArrayItemType> ItemArrayType;
TFastArrayReplicationFragment(EReplicationFragmentTraits InTraits,
UObject* InOwner,
const FReplicationStateDescriptor* InDescriptor,
bool bValidateDescriptor = true);
protected:
virtual void ApplyReplicatedState(FReplicationStateApplyContext& Context) const override;
virtual void CallRepNotifies(FReplicationStateApplyContext& Context) override;
virtual bool PollReplicatedState(EReplicationFragmentPollFlags PollOption) override;
// 轮询整个 FastArray
bool PollAllState(bool bForceFullCompare = false);
// 检查是否脏
bool IsDirty() const;
// 标记为脏
void MarkDirty();
// 从不同来源获取 FastArraySerializer
FastArrayType* GetFastArraySerializerFromOwner() const;
FastArrayType* GetFastArraySerializerFromReplicationState() const;
FastArrayType* GetFastArraySerializerFromApplyContext(FReplicationStateApplyContext& Context) const;
private:
TUniquePtr<FastArrayType> AccumulatedReceivedState; // 累积接收的状态
};🎮 使用场景:
CPP
// 背包系统 - 物品可能频繁增删USTRUCT()
struct FInventoryItem : public FFastArraySerializerItem
{
UPROPERTY()
int32 ItemId;
UPROPERTY()
int32 Count;
};
USTRUCT()
struct FInventoryArray : public FFastArraySerializer
{
UPROPERTY()
TArray<FInventoryItem> Items; // ← 用 TFastArrayReplicationFragment 处理
};🚀 FastArray Fragment 的优势:
PLAINTEXT
❌ 普通数组复制:
每次变化都发送整个数组 [Item1, Item2, Item3, Item4, Item5]
↑ 即使只有 Item3 变了,也要发送全部
✅ FastArray 复制:
只发送变化的元素 [修改: Item3]
↑ 只发送变化的部分,大大节省带宽!特性 | 说明 |
|---|---|
📦 增量更新 | 只发送变化的元素 |
🔑 ReplicationKey | 用于快速检测变化 |
✅ 元素级脏标记 | 每个元素独立追踪变化 |
📖 2.4 ReplicationProtocol(复制协议)
📌 概述
FReplicationProtocol 定义了一个复制对象的完整状态结构。它是 FReplicationStateDescriptor 的集合,描述了对象所有需要复制的状态。
💡 新手理解:如果说
FReplicationStateDescriptor是"单个属性组的说明书",那么FReplicationProtocol就是"整本说明书" 📚——它把一个对象所有需要复制的属性组都汇总在一起。
❓ 为什么需要 Protocol?
PLAINTEXT
一个复杂的 Actor 可能有多种类型的复制状态:
AComplexActor
├─► 常规状态 (RegularState)
│ └─► Health, Position, Rotation(每帧都可能变化)
│
├─► 初始化状态 (InitState)
│ └─► ActorName, TeamId(只在创建时发送一次)
│
└─► 条件状态 (ConditionalState)
└─► SecretData(只发给特定客户端)
Protocol 把这些状态组织在一起:
┌─────────────────────────────────────────────────────────────────────────┐
│ FReplicationProtocol │
│ │
│ ReplicationStateDescriptors[] = [ │
│ Descriptor[0]: InitState(初始化状态) │
│ Descriptor[1]: RegularState(常规状态) │
│ Descriptor[2]: ConditionalState(条件状态) │
│ ] │
│ │
│ ReplicationStateCount = 3 │
│ ProtocolIdentifier = 0x12345678(用于匹配) │
└─────────────────────────────────────────────────────────────────────────┘📋 协议的定义与作用
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Private/Iris/ReplicationSystem/ReplicationProtocol.h
struct FReplicationProtocol
{
// ===== 引用计数 =====
void AddRef() const;
void Release() const;
int32 GetRefCount() const { return RefCount; }
// ===== 状态描述符 =====
const FReplicationStateDescriptor** ReplicationStateDescriptors; // 状态描述符数组
uint32 ReplicationStateCount; // 状态数量
// ===== 内存布局 =====
uint32 InternalTotalSize; // 内部状态总大小
uint32 InternalTotalAlignment; // 内部状态对齐
uint32 MaxExternalStateSize; // 最大外部状态大小
uint32 MaxExternalStateAlignment; // 最大外部状态对齐
// ===== 条件复制 =====
uint16 FirstLifetimeConditionalsStateIndex; // 第一个条件状态索引
uint16 LifetimeConditionalsStateCount; // 条件状态数量
uint32 FirstLifetimeConditionalsChangeMaskOffset; // 条件变化掩码偏移
// ===== 变化掩码 =====
uint32 ChangeMaskBitCount; // 变化掩码总位数
uint32 InternalChangeMasksOffset; // 内部变化掩码偏移
// ===== 标识 =====
FReplicationProtocolIdentifier ProtocolIdentifier; // 协议标识符
const FNetDebugName* DebugName; // 调试名称
int32 TypeStatsIndex; // 类型统计索引
// ===== 特性 =====
EReplicationProtocolTraits ProtocolTraits;
// ===== 引用计数 =====
mutable std::atomic<int32> RefCount;
// 获取条件变化掩码偏移
uint32 GetConditionalChangeMaskOffset() const { return InternalChangeMasksOffset; }
};🏷️ 协议特性
CPP
enum class EReplicationProtocolTraits : uint16
{
None = 0,
HasDynamicState = 1U << 0, // 有动态状态
HasLifetimeConditionals = 1U << 1, // 有生命周期条件
HasConditionalChangeMask = 1U << 2, // 有条件变化掩码
HasConnectionSpecificSerialization = 1U << 3, // 有连接特定序列化
HasObjectReference = 1U << 4, // 有对象引用
SupportsDeltaCompression = 1U << 5, // 支持增量压缩
};🎯 实例协议
FReplicationInstanceProtocol 存储与特定对象实例相关的信息:
CPP
struct FReplicationInstanceProtocol
{
// Fragment 数据
struct FFragmentData
{
uint8* ExternalSrcBuffer; // 外部源缓冲区指针
};
FFragmentData* FragmentData; // Fragment 数据数组
FReplicationFragment* const* Fragments; // Fragment 指针数组
uint16 FragmentCount; // Fragment 数量
EReplicationInstanceProtocolTraits InstanceTraits; // 实例特性
};
enum class EReplicationInstanceProtocolTraits : uint16
{
None = 0,
NeedsPoll = 1, // 需要轮询
NeedsLegacyCallbacks = 1 << 1, // 需要旧版回调
IsBound = 1 << 2, // 已绑定
NeedsPreSendUpdate = 1 << 3, // 需要 PreSendUpdate
NeedsWorldLocationUpdate = 1 << 4, // 需要世界位置更新
HasPartialPushBasedDirtiness = 1 << 5, // 部分 Push Model
HasFullPushBasedDirtiness = 1 << 6, // 完全 Push Model
HasObjectReference = 1 << 7, // 有对象引用
};🔗 协议匹配机制
💡 新手理解:服务器和客户端必须对"这个对象长什么样"达成一致。Protocol 的 Identifier 就是用来确认双方理解一致的。
协议通过 FReplicationProtocolIdentifier 进行匹配:
CPP
typedef uint32 FReplicationProtocolIdentifier;🔍 匹配流程:
PLAINTEXT
服务器 客户端
┌─────────────────┐ ┌─────────────────┐
│ 创建对象 │ │ │
│ ProtocolId: 123 │ │ │
└────────┬────────┘ └────────┬────────┘
│ │
│ 发送创建消息 (ProtocolId: 123) │
│───────────────────────────────────────►│
│ │
│ ┌────────┴────────┐
│ │ 查找 Protocol │
│ │ by ProtocolId │
│ └────────┬────────┘
│ │
│ ┌────────┴────────┐
│ │ 匹配成功? │
│ │ ├─ 是:创建对象 │
│ │ └─ 否:报错 │
│ └─────────────────┘⚠️ 为什么需要协议匹配?
PLAINTEXT
问题场景:服务器和客户端版本不一致
服务器(新版本):
AEnemy 有 4 个属性:Health, Position, Rotation, Armor
客户端(旧版本):
AEnemy 有 3 个属性:Health, Position, Rotation
如果不检查,客户端收到 Armor 数据会不知道怎么处理!
解决方案:
- 服务器和客户端各自计算 ProtocolIdentifier(基于属性结构的哈希)
- 如果 Identifier 不匹配,说明版本不兼容,报错提示🔄 版本兼容性处理
💡 新手理解:这是 Iris 用来检测"服务器和客户端是否兼容"的机制。
Iris 通过 FReplicationStateIdentifier 处理版本兼容性:
CPP
struct FReplicationStateIdentifier
{
uint64 Value; // 状态名称的 CityHash(哈希值)
uint64 DefaultStateHash; // 默认状态的 CityHash
bool operator==(const FReplicationStateIdentifier& Other) const
{
return Value == Other.Value;
}
};✅ 兼容性检查流程:
PLAINTEXT
1. 比较 ProtocolIdentifier
└─► 确保整体协议结构匹配
2. 比较各状态的 DescriptorIdentifier
└─► 确保每个状态的成员结构一致
3. 可选:比较 DefaultStateHash
└─► 确保默认值一致(更严格的检查)💡 实际意义:如果你修改了一个类的复制属性(增加、删除或改变类型),ProtocolIdentifier 会变化,旧版本的客户端就无法连接新版本的服务器,避免出现难以调试的数据错乱问题。
🏭 2.5 描述符构建器(Descriptor Builder)
📌 概述
前面我们详细介绍了 FReplicationStateDescriptor 的结构和作用,但你可能会问:这些描述符是怎么来的?谁负责创建它们?
答案是:描述符构建器(Descriptor Builder)。
💡 新手理解:如果把
FReplicationStateDescriptor比作"体检报告模板",那么 Builder 就是"制作模板的工厂"。它读取 UClass/UStruct 的反射信息,自动生成对应的描述符。
❓ 为什么需要 Builder?
PLAINTEXT
问题:Iris 需要知道每个类有哪些属性需要复制,但这些信息散落在代码各处:
UCLASS()
class AEnemy : public AActor
{
UPROPERTY(Replicated) // 反射信息1
float Health;
UPROPERTY(Replicated) // 反射信息2
FVector Position;
UPROPERTY(ReplicatedUsing=OnRep_Rotation) // 反射信息3 + RepNotify
FRotator Rotation;
};
Builder 的作用:
┌─────────────────────────────────────────────────────────────────────────┐
│ FReplicationStateDescriptorBuilder │
│ │
│ 输入:UClass* (AEnemy) │
│ │ │
│ ├─► 1. 遍历所有 UPROPERTY │
│ ├─► 2. 筛选带 Replicated 标记的属性 │
│ ├─► 3. 收集每个属性的: │
│ │ - 内存偏移 (ExternalOffset) │
│ │ - 数据类型 → 选择合适的 NetSerializer │
│ │ - 复制条件 (COND_OwnerOnly 等) │
│ │ - RepNotify 回调函数 │
│ ├─► 4. 计算紧凑的内部内存布局 (InternalOffset) │
│ ├─► 5. 分配 ChangeMask 位 │
│ │ │
│ 输出:FReplicationStateDescriptor (完整的属性说明书) │
└─────────────────────────────────────────────────────────────────────────┘🏗️ Builder 的两层结构
Iris 的描述符构建采用两层设计:
层级 | 类名 | 位置 | 作用 |
|---|---|---|---|
公开 API |
| 头文件 | 提供静态工厂方法,供外部调用 |
内部实现 |
| cpp 文件 | 实际执行构建逻辑 |
PLAINTEXT
调用关系:
┌─────────────────────────────────────────┐
│ 外部代码 │
│ ReplicationSystem->StartReplicating() │
└────────────────┬────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────┐
│ FReplicationStateDescriptorBuilder │ ← 公开 API(头文件)
│ ::CreateDescriptorsForClass() │
└────────────────┬────────────────────────┘
│ 内部使用
▼
┌─────────────────────────────────────────┐
│ FPropertyReplicationStateDescriptorBuilder │ ← 实现细节(cpp 文件)
│ 收集属性 → 计算布局 → 填充描述符 │
└─────────────────────────────────────────┘📖 FReplicationStateDescriptorBuilder(公开 API)
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationState/ReplicationStateDescriptorBuilder.h
class FReplicationStateDescriptorBuilder
{
public:
// ===== 构建参数 =====
struct FParameters
{
FReplicationStateDescriptorRegistry* DescriptorRegistry; // 描述符缓存(避免重复创建)
UReplicationSystem* ReplicationSystem; // 复制系统实例
UObject* DefaultStateSource; // 默认状态来源(否则用 CDO)
uint32 IncludeSuper : 1; // 是否包含父类属性
uint32 GetLifeTimeProperties : 1; // 获取生命周期条件属性
uint32 EnableFastArrayHandling : 1; // 启用 FastArray 特殊处理
int32 SinglePropertyIndex; // 只构建指定索引的属性(-1 表示全部)
};
// ===== 静态工厂方法 =====
// 为 UClass 创建描述符(Actor、Component 等)
// 注意:一个 Class 可能产生多个描述符(InitState、RegularState、ConditionalState)
static SIZE_T CreateDescriptorsForClass(
FResult& OutCreatedDescriptors, // 输出:描述符数组
UClass* InClass, // 输入:要分析的类
const FParameters& Parameters // 参数配置
);
// 为 UStruct 创建描述符(普通结构体)
static TRefCountPtr<const FReplicationStateDescriptor> CreateDescriptorForStruct(
const UStruct* InStruct,
const FParameters& Parameters
);
// 为 UFunction 创建描述符(RPC 函数的参数)
static TRefCountPtr<const FReplicationStateDescriptor> CreateDescriptorForFunction(
const UFunction* Function,
const FParameters& Parameters
);
};📋 三种工厂方法对比:
方法 | 输入 | 输出 | 典型用途 |
|---|---|---|---|
| UClass* | 多个描述符 | Actor、ActorComponent 等 |
| UStruct* | 单个描述符 | 普通 USTRUCT 结构体 |
| UFunction* | 单个描述符 | RPC 函数的参数列表 |
🤔 为什么 Class 会产生多个描述符?
PLAINTEXT
一个 UClass 的复制属性可能有不同的特性,需要拆分处理:
AMyActor 的复制属性:
┌─────────────────────────────────────────────────────────────────────────┐
│ UPROPERTY(Replicated) │
│ FString CharacterName; ← 初始化属性,只同步一次 │
│ │
│ UPROPERTY(Replicated) │
│ float Health; ← 常规属性,持续同步 │
│ │
│ UPROPERTY(Replicated, Condition=COND_OwnerOnly) │
│ int32 Ammo; ← 条件属性,只同步给 Owner │
└─────────────────────────────────────────────────────────────────────────┘
拆分成 3 个描述符:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ InitState │ │ RegularState │ │ ConditionalState│
│ - CharacterName │ │ - Health │ │ - Ammo │
│ (只同步一次) │ │ (持续同步) │ │ (按条件同步) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
优势:
1. 共享优化 - 相同特性的属性组可被多个连接共享
2. 按需同步 - Init 属性只在对象创建时发送
3. 条件过滤 - 不同条件的属性分开管理,互不干扰🔧 FPropertyReplicationStateDescriptorBuilder(内部实现)
这是真正执行构建工作的类,定义在 cpp 文件中(对外不可见):
CPP
// 位于: Engine/Source/Runtime/Experimental/Iris/Core/Private/Iris/ReplicationState/ReplicationStateDescriptorBuilder.cpp
class FPropertyReplicationStateDescriptorBuilder
{
// ===== 输入数据 =====
TArray<FMemberProperty> Members; // 收集到的成员属性
TArray<FMemberFunction> Functions; // 收集到的成员函数(RPC)
FStructInfo StructInfo; // 结构体信息
// ===== 核心方法 =====
TRefCountPtr<const FReplicationStateDescriptor> Build(...);
private:
// 构建缓存(中间数据)
TArray<FMemberCacheEntry> MemberCache;
// 构建流程的各个阶段
void BuildMemberCache(); // 阶段1:缓存序列化器信息
void BuildMemberTagCache(); // 阶段2:构建 RepTag 缓存
void BuildMemberReferenceCache(); // 阶段3:构建对象引用缓存
void BuildMemberDescriptors(); // 阶段4:填充成员描述符
void BuildMemberChangeMaskDescriptors(); // 阶段5:分配 ChangeMask 位
void BuildMemberSerializerDescriptors(); // 阶段6:填充序列化器描述符
void BuildMemberTraitsDescriptors(); // 阶段7:填充特征描述符
void AllocateAndInitializeDefaultInternalStateBuffer(); // 阶段8:初始化默认状态
void FinalizeDescriptor(); // 阶段9:最终验证和设置
};📊 FMemberProperty 成员属性信息:
CPP
struct FMemberProperty
{
const FProperty* Property; // UE 反射属性指针
const UFunction* PropertyRepNotifyFunction; // RepNotify 回调函数
const FPropertyNetSerializerInfo* SerializerInfo; // 序列化器信息
EMemberPropertyTraits Traits; // 属性特征标志
ELifetimeCondition ReplicationCondition; // 复制条件 (COND_OwnerOnly 等)
uint16 ChangeMaskBits; // 需要的 ChangeMask 位数
FName ChangeMaskGroupName; // ChangeMask 分组名(共享位)
};📊 FMemberCacheEntry 构建缓存:
CPP
struct FMemberCacheEntry
{
const FNetSerializer* Serializer; // 选定的网络序列化器
TRefCountPtr<const FReplicationStateDescriptor> Descriptor; // 嵌套结构的描述符
FSizeAndAlignment ExternalSizeAndAlignment; // 原始数据大小/对齐
FSizeAndAlignment InternalSizeAndAlignment; // 量化后数据大小/对齐
uint32 bIsStruct : 1; // 是否结构体(需要递归处理)
uint32 bIsDynamicArray : 1; // 是否动态数组
uint32 bHasCustomObjectReference : 1; // 有自定义对象引用
};🔄 构建流程详解
PLAINTEXT
Build() 主流程
│
├─► 1. BuildMemberCache()
│ │
│ ├─► 遍历所有 FMemberProperty
│ ├─► 为每个属性选择合适的 NetSerializer
│ │ └─► float → FFloatNetSerializer
│ │ └─► FVector → FVectorNetSerializer
│ │ └─► UObject* → FObjectNetSerializer
│ ├─► 计算每个属性的 ExternalSize / InternalSize
│ └─► 处理嵌套结构(递归创建子描述符)
│
├─► 2. 计算总体内存布局
│ ├─► ExternalSize = Σ(所有属性的 ExternalSize) + 对齐
│ └─► InternalSize = Σ(所有属性的 InternalSize) + 对齐
│
├─► 3. 分配描述符内存块
│ │
│ │ ┌─────────────────────────────────────────────┐
│ │ │ FReplicationStateDescriptor │
│ │ ├─────────────────────────────────────────────┤
│ │ │ MemberDescriptors[MemberCount] │
│ │ │ MemberChangeMaskDescriptors[MemberCount] │
│ │ │ MemberSerializerDescriptors[MemberCount] │
│ │ │ MemberTraitsDescriptors[MemberCount] │
│ │ │ DefaultStateBuffer[InternalSize] │
│ │ │ ...其他数据... │
│ │ └─────────────────────────────────────────────┘
│ │ 一次分配,紧凑排列,缓存友好
│ │
├─► 4. BuildMemberDescriptors()
│ ├─► 填充 ExternalMemberOffset(在游戏对象中的偏移)
│ └─► 填充 InternalMemberOffset(在复制缓冲区中的偏移)
│
├─► 5. BuildMemberChangeMaskDescriptors()
│ ├─► 为每个属性分配 BitOffset
│ ├─► 设置 BitCount(普通属性=1,数组可能>1)
│ └─► 计算总 ChangeMaskBitCount
│
├─► 6. BuildMemberSerializerDescriptors()
│ ├─► 填充 Serializer 指针
│ └─► 填充 SerializerConfig(量化参数等)
│
├─► 7. BuildMemberTraitsDescriptors()
│ └─► 填充每个成员的 Traits 标志
│
├─► 8. AllocateAndInitializeDefaultInternalStateBuffer()
│ ├─► 从 CDO 或 DefaultStateSource 获取默认值
│ └─► 序列化到 DefaultStateBuffer
│
└─► 9. FinalizeDescriptor()
├─► 计算 DescriptorIdentifier(用于版本匹配)
├─► 设置 Traits 标志
└─► 返回 TRefCountPtr<const FReplicationStateDescriptor>📊 构建示例:AEnemy 类
PLAINTEXT
输入:┌─────────────────────────────────────────────────────────────────────────┐│ class AEnemy : public AActor ││ { ││ UPROPERTY(Replicated) ││ float Health; // offset=64, size=4 ││ ││ UPROPERTY(Replicated) ││ FVector Position; // offset=68, size=12 ││ ││ UPROPERTY(ReplicatedUsing=OnRep_Rotation) ││ FRotator Rotation; // offset=80, size=12 ││ }; │└─────────────────────────────────────────────────────────────────────────┘
Builder 处理过程:┌─────────────────────────────────────────────────────────────────────────┐│ 1. BuildMemberCache: ││ Health → FFloatNetSerializer, Internal=4bytes ││ Position → FVectorNetSerializer, Internal=12bytes (可配置量化) ││ Rotation → FRotatorNetSerializer, Internal=12bytes (可配置量化) ││ ││ 2. 计算布局: ││ ExternalSize = 对齐后的游戏对象布局 ││ InternalSize = 4 + 12 + 12 = 28 bytes (紧凑) ││ ││ 3. BuildMemberDescriptors: ││ [0] Health: External=64, Internal=0 ││ [1] Position: External=68, Internal=4 ││ [2] Rotation: External=80, Internal=16 ││ ││ 4. BuildMemberChangeMaskDescriptors: ││ [0] Health: BitOffset=0, BitCount=1 ││ [1] Position: BitOffset=1, BitCount=1 ││ [2] Rotation: BitOffset=2, BitCount=1 ││ ChangeMaskBitCount = 3 ││ ││ 5. BuildMemberSerializerDescriptors: ││ [0] Serializer=FFloatNetSerializer, Config=默认 ││ [1] Serializer=FVectorNetSerializer, Config=量化配置 ││ [2] Serializer=FRotatorNetSerializer, Config=量化配置 ││ ││ 6. BuildMemberTraitsDescriptors: ││ [0] Traits=None ││ [1] Traits=None ││ [2] Traits=HasRepNotifyAlways (因为有 OnRep_Rotation) │└─────────────────────────────────────────────────────────────────────────┘
输出:┌─────────────────────────────────────────────────────────────────────────┐│ FReplicationStateDescriptor ││ MemberCount = 3 ││ ChangeMaskBitCount = 3 ││ ExternalSize = ... ││ InternalSize = 28 ││ Traits = HasRepNotifies ││ ││ MemberDescriptors[] = [{64,0}, {68,4}, {80,16}] ││ MemberChangeMaskDescriptors[] = [{0,1}, {1,1}, {2,1}] ││ MemberSerializerDescriptors[] = [Float, Vector, Rotator] ││ MemberTraitsDescriptors[] = [None, None, HasRepNotifyAlways] │└─────────────────────────────────────────────────────────────────────────┘🔑 关键设计亮点
特性 | 说明 | 优势 |
|---|---|---|
描述符复用 | 通过 DescriptorRegistry 缓存,相同类型的对象共享描述符 | 节省内存 |
一次分配 | 描述符和所有子数组在一块连续内存中 | 缓存友好 |
引用计数 |
| 自动内存管理 |
状态拆分 | 根据 Init/Regular/Conditional 拆分成多个 State | 按需同步 |
递归处理 | 自动处理嵌套结构体,创建子描述符 | 支持复杂数据 |
📁 关键源文件索引
类 | 文件路径 |
|---|---|
🏗️ FReplicationStateDescriptorBuilder |
|
🔧 FPropertyReplicationStateDescriptorBuilder |
|
🔗 四个核心数据结构的关系总结
⚠️ 新手必读:如果你只能记住一张图,就记住这张:
PLAINTEXT
┌─────────────────────────────────────────────────────────────────────────┐│ 游戏对象 (AEnemy) ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ Health = 80 │ ││ │ Position = (100, 200, 50) │ ││ │ Rotation = (0, 90, 0) │ ││ └─────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘
│
│ 绑定
▼┌─────────────────────────────────────────────────────────────────────────┐│ FReplicationFragment (快递员) ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ Owner: 指向 AEnemy │ ││ │ Descriptor: 指向属性说明书 │ ││ │ │ ││ │ 职责: │ ││ │ PollReplicatedState() → 检测属性变化 │ ││ │ ApplyReplicatedState() → 应用收到的数据 │ ││ │ CallRepNotifies() → 调用回调函数 │ ││ └─────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘
│
│ 使用
▼┌─────────────────────────────────────────────────────────────────────────┐│ FReplicationStateDescriptor (属性说明书) ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ MemberDescriptors: 每个属性在哪里 │ ││ │ MemberSerializers: 每个属性怎么序列化 │ ││ │ MemberChangeMasks: 每个属性的变化标记位置 │ ││ └─────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘
│
│ 组成
▼┌─────────────────────────────────────────────────────────────────────────┐│ FReplicationProtocol (规则手册) ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ ReplicationStateDescriptors[]: 所有状态说明书的集合 │ ││ │ ProtocolIdentifier: 用于版本匹配 │ ││ └─────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘
│
│ 标识
▼┌─────────────────────────────────────────────────────────────────────────┐│ FNetRefHandle (身份证号) ││ ┌─────────────────────────────────────────────────────────────────┐ ││ │ Id = 2 (动态句柄) │ ││ │ ReplicationSystemId = 1 │ ││ │ │ ││ │ 作用:服务器和客户端都用这个 ID 指代同一个 AEnemy │ ││ └─────────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘📋 一句话总结每个结构的作用:
结构 | 一句话 |
|---|---|
🏷️ FNetRefHandle | "这是哪个对象"——网络身份证 |
📝 FReplicationStateDescriptor | "这个对象有什么属性"——属性说明书 |
🚚 FReplicationFragment | "怎么读写这些属性"——数据搬运工 |
📖 FReplicationProtocol | "这类对象的完整规则"——规则手册 |
🗺️ 数据结构关系图
PLAINTEXT
┌─────────────────────────────────────────────────────────────────────────┐
│ FReplicationProtocol │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ReplicationStateDescriptors[] │ │
│ │ ├─► FReplicationStateDescriptor[0] (InitState) │ │
│ │ ├─► FReplicationStateDescriptor[1] (RegularState) │ │
│ │ └─► FReplicationStateDescriptor[2] (ConditionalState) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ProtocolIdentifier, ChangeMaskBitCount, ProtocolTraits... │
└─────────────────────────────────────────────────────────────────────────┘
│
│ 描述
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ FReplicationStateDescriptor │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ MemberDescriptors[] │ │ MemberSerializers[] │ │
│ │ ├─► Health: 0→4 │ │ ├─► FloatSerializer │ │
│ │ ├─► Position: 4→8 │ │ ├─► VectorSerializer │ │
│ │ └─► Rotation: 16→20 │ │ └─► RotatorSerializer│ │
│ └───────────────────────┘ └───────────────────────┘ │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ MemberChangeMasks[] │ │ MemberTraits[] │ │
│ │ ├─► Bit 0, Count 1 │ │ ├─► None │ │
│ │ ├─► Bit 1, Count 1 │ │ ├─► HasObjectRef │ │
│ │ └─► Bit 2, Count 1 │ │ └─► HasRepNotify │ │
│ └───────────────────────┘ └───────────────────────┘ │
│ ExternalSize, InternalSize, Traits, DescriptorIdentifier... │
└─────────────────────────────────────────────────────────────────────────┘
│
│ 绑定
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ FReplicationFragment │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ FPropertyReplicationFragment │ │
│ │ ├─► Owner: UObject* │ │
│ │ ├─► SrcReplicationState │ │
│ │ └─► ReplicationStateDescriptor │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ TFastArrayReplicationFragment │ │
│ │ ├─► Owner: UObject* │ │
│ │ ├─► AccumulatedReceivedState │ │
│ │ └─► ReplicationStateDescriptor │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ Traits, ApplyReplicatedState(), PollReplicatedState()... │
└─────────────────────────────────────────────────────────────────────────┘
│
│ 标识
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ FNetRefHandle │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 64-bit Value │ │
│ │ ├─► Id (60 bits): 唯一标识符 │ │
│ │ │ └─► Bit 0: Static/Dynamic 标志 │ │
│ │ └─► ReplicationSystemId (4 bits): PIE 实例 ID │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ IsValid(), IsStatic(), IsDynamic(), GetId()... │
└─────────────────────────────────────────────────────────────────────────┘📁 关键源文件索引
数据结构 | 文件路径 |
|---|---|
🏷️ FNetRefHandle |
|
📝 FReplicationStateDescriptor |
|
🏗️ FReplicationStateDescriptorBuilder |
|
🚚 FReplicationFragment |
|
📦 FPropertyReplicationFragment |
|
⚡ TFastArrayReplicationFragment |
|
📖 FReplicationProtocol |
|
🔧 FNetRefHandleManager |
|
📝 小结
第三部分详细介绍了 Iris 的四个核心数据结构:
1️⃣ FNetRefHandle(网络对象句柄)
🎯 作用:64 位句柄,用于唯一标识网络对象
🔢 结构:60 位 ID + 4 位 ReplicationSystemId
✨ 特点:支持静态/动态区分(通过最低位判断奇偶)
💡 新手记忆:对象的"网络身份证号"
2️⃣ FReplicationStateDescriptor(复制状态描述符)
🎯 作用:描述复制状态的元数据
📦 包含:成员偏移、序列化器、变化掩码等信息
✨ 特点:区分外部偏移(游戏对象)和内部偏移(复制缓冲区)
💡 新手记忆:对象的"属性说明书"
3️⃣ FReplicationFragment(复制片段)
🎯 作用:连接游戏对象和复制状态的桥梁
⚙️ 职责:Poll(检测变化)、Apply(应用数据)、CallRepNotifies(调用回调)
📦 类型:PropertyReplicationFragment(普通属性)、FastArrayReplicationFragment(数组)
💡 新手记忆:数据的"搬运工/快递员"
4️⃣ FReplicationProtocol(复制协议)
🎯 作用:定义对象的完整复制结构
📦 包含:多个 StateDescriptor 的集合
✨ 特点:通过 ProtocolIdentifier 进行版本匹配
💡 新手记忆:对象的"完整规则手册"
5️⃣ FReplicationStateDescriptorBuilder(描述符构建器)
🎯 作用:从 UClass/UStruct 的反射信息构建 Descriptor
🏗️ 结构:公开 API(FReplicationStateDescriptorBuilder)+ 内部实现(FPropertyReplicationStateDescriptorBuilder)
✨ 特点:一次分配、缓存复用、自动处理嵌套结构
💡 新手记忆:"属性说明书"的自动生成工厂
这些数据结构共同构成了 Iris 的数据模型基础,为高效的网络复制提供了支撑。
🎓 下一步学习建议
如果你是新手,建议按以下顺序深入学习:
1️⃣ 先理解 FNetRefHandle:这是最简单的,就是一个 ID
2️⃣ 再理解 Fragment 的工作流程:Poll → Serialize → Send → Receive → Deserialize → Apply
3️⃣ 然后看 Descriptor 的细节:理解偏移、序列化器、变化掩码
4️⃣ 理解 Builder 如何创建 Descriptor:从反射信息到描述符的转换过程
5️⃣ 最后看 Protocol:理解多个 Descriptor 如何组合
👉 下一部分将深入分析 Iris 的核心组件:UReplicationSystem、UReplicationBridge、UObjectReplicationBridge 和 UEngineReplicationBridge。
本文档基于 Unreal Engine 5.5.0 Iris 源代码分析(源码目录:Engine/Source/Runtime/Experimental/Iris/)